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
@@ -1,9 +1,7 @@
import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter';
import { currentUserState } from '@/auth/states/currentUserState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RouterProvider } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated/graphql';
export const AppRouter = () => {
// We want to disable serverless function settings but keep the code for now
@@ -15,17 +13,9 @@ export const AppRouter = () => {
(currentUser?.canImpersonate || currentUser?.canAccessFullAdminPanel) ??
false;
const isPageLayoutFeatureFlagEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED,
);
return (
<RouterProvider
router={useCreateAppRouter(
isFunctionSettingsEnabled,
isAdminPageEnabled,
isPageLayoutFeatureFlagEnabled,
)}
router={useCreateAppRouter(isFunctionSettingsEnabled, isAdminPageEnabled)}
/>
);
};
@@ -287,20 +287,6 @@ const SettingsObjectFieldEdit = lazy(() =>
),
);
const SettingsPageLayouts = lazy(() =>
import('~/pages/settings/page-layout/SettingsPageLayouts').then((module) => ({
default: module.SettingsPageLayouts,
})),
);
const SettingsPageLayoutEdit = lazy(() =>
import('~/pages/settings/page-layout/SettingsPageLayoutEdit').then(
(module) => ({
default: module.SettingsPageLayoutEdit,
}),
),
);
const SettingsSecurity = lazy(() =>
import('~/pages/settings/security/SettingsSecurity').then((module) => ({
default: module.SettingsSecurity,
@@ -386,13 +372,11 @@ const SettingsRoleAddObjectLevel = lazy(() =>
type SettingsRoutesProps = {
isFunctionSettingsEnabled?: boolean;
isAdminPageEnabled?: boolean;
isPageLayoutFeatureFlagEnabled?: boolean;
};
export const SettingsRoutes = ({
isFunctionSettingsEnabled,
isAdminPageEnabled,
isPageLayoutFeatureFlagEnabled,
}: SettingsRoutesProps) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@@ -614,22 +598,7 @@ export const SettingsRoutes = ({
/>
</>
)}
{isPageLayoutFeatureFlagEnabled && (
<>
<Route
path={SettingsPath.PageLayout}
element={<SettingsPageLayouts />}
/>
<Route
path={SettingsPath.PageLayoutNew}
element={<SettingsPageLayoutEdit />}
/>
<Route
path={SettingsPath.PageLayoutEdit}
element={<SettingsPageLayoutEdit />}
/>
</>
)}
<Route
element={
<SettingsProtectedRouteWrapper
@@ -31,7 +31,6 @@ import { SyncEmails } from '~/pages/onboarding/SyncEmails';
export const useCreateAppRouter = (
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
isPageLayoutFeatureFlagEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
@@ -70,7 +69,6 @@ export const useCreateAppRouter = (
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
isPageLayoutFeatureFlagEnabled={isPageLayoutFeatureFlagEnabled}
/>
}
/>
@@ -6,14 +6,10 @@ import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuP
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const StyledCommandMenuContent = styled.div`
@@ -24,8 +20,6 @@ const StyledCommandMenuContent = styled.div`
export const CommandMenuRouter = () => {
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const location = useLocation();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const commandMenuPageComponent = isDefined(commandMenuPage) ? (
COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage)
@@ -35,13 +29,6 @@ export const CommandMenuRouter = () => {
const theme = useTheme();
const isSettingsPage = location.pathname.includes(SettingsPath.PageLayout);
const objectMetadataItemOverride = isSettingsPage
? objectMetadataItems.find(
(item) => item.nameSingular === CoreObjectNameSingular.Dashboard,
)
: undefined;
return (
<CommandMenuContainer>
<CommandMenuContextChipRecordSetterEffect />
@@ -64,7 +51,6 @@ export const CommandMenuRouter = () => {
isInRightDrawer={true}
displayType="listItem"
actionMenuType="command-menu"
objectMetadataItemOverride={objectMetadataItemOverride}
>
{commandMenuPageComponent}
</ActionMenuContextProvider>
@@ -1,4 +1,5 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useCreatePageLayoutWidget } from '@/page-layout/hooks/useCreatePageLayoutWidget';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import styled from '@emotion/styled';
@@ -57,7 +58,10 @@ const graphTypeOptions = [
export const CommandMenuPageLayoutGraphTypeSelect = () => {
const { closeCommandMenu } = useCommandMenu();
const { createPageLayoutWidget } = useCreatePageLayoutWidget();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { createPageLayoutWidget } = useCreatePageLayoutWidget(pageLayoutId);
const handleSelectGraphType = (graphType: GraphType) => {
createPageLayoutWidget(WidgetType.GRAPH, graphType);
@@ -1,13 +1,15 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
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 { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { isString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isValidUrl } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
@@ -33,21 +35,37 @@ const StyledButtonContainer = styled.div`
export const CommandMenuPageLayoutIframeConfig = () => {
const { closeCommandMenu } = useCommandMenu();
const { createPageLayoutIframeWidget } = useCreatePageLayoutIframeWidget();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { createPageLayoutIframeWidget } =
useCreatePageLayoutIframeWidget(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] =
useRecoilState(pageLayoutEditingWidgetIdState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
useRecoilComponentState(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const pageLayoutDraft = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const editingWidget = allWidgets.find(
(w) => w.id === pageLayoutEditingWidgetId,
);
const isEditMode = !!editingWidget;
const [title, setTitle] = useState(editingWidget?.title || '');
const configUrl = editingWidget?.configuration?.url;
const [url, setUrl] = useState(isString(configUrl) ? configUrl : '');
const [urlError, setUrlError] = useState('');
const validateUrl = (urlString: string): boolean => {
@@ -84,6 +102,7 @@ export const CommandMenuPageLayoutIframeConfig = () => {
url: url.trim(),
},
});
setPageLayoutEditingWidgetId(null);
} else {
createPageLayoutIframeWidget(title.trim(), url.trim());
@@ -1,9 +1,10 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { WidgetType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { IconChartPie, IconFrame, IconList } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
@@ -50,27 +51,31 @@ const widgetTypeOptions = [
];
export const CommandMenuPageLayoutWidgetTypeSelect = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const setPageLayoutDraggedArea = useSetRecoilState(
pageLayoutDraggedAreaState,
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const setPageLayoutDraggedArea = useSetRecoilComponentState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const handleSelectWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case WidgetType.GRAPH:
navigateCommandMenu({
page: CommandMenuPages.PageLayoutGraphTypeSelect,
pageTitle: 'Select Graph Type',
pageIcon: IconChartPie,
case WidgetType.GRAPH: {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphTypeSelect,
});
break;
case WidgetType.IFRAME:
navigateCommandMenu({
page: CommandMenuPages.PageLayoutIframeConfig,
pageTitle: 'Configure iFrame',
pageIcon: IconFrame,
}
case WidgetType.IFRAME: {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutIframeConfig,
});
break;
}
default:
setPageLayoutDraggedArea(null);
break;
@@ -0,0 +1,42 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { getPageLayoutIcon } from '@/command-menu/pages/page-layout/utils/getPageLayoutIcon';
import { getPageLayoutPageTitle } from '@/command-menu/pages/page-layout/utils/getPageLayoutPageTitle';
import { type CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
type NavigatePageLayoutCommandMenuProps = {
commandMenuPage:
| CommandMenuPages.PageLayoutWidgetTypeSelect
| CommandMenuPages.PageLayoutGraphTypeSelect
| CommandMenuPages.PageLayoutIframeConfig;
pageTitle?: string;
pageIcon?: IconComponent;
};
export const useNavigatePageLayoutCommandMenu = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const navigatePageLayoutCommandMenu = useRecoilCallback(() => {
return ({
commandMenuPage,
pageTitle,
pageIcon,
}: NavigatePageLayoutCommandMenuProps) => {
navigateCommandMenu({
page: commandMenuPage,
pageTitle: isDefined(pageTitle)
? pageTitle
: getPageLayoutPageTitle(commandMenuPage),
pageIcon: isDefined(pageIcon)
? pageIcon
: getPageLayoutIcon(commandMenuPage),
});
};
}, [navigateCommandMenu]);
return {
navigatePageLayoutCommandMenu,
};
};
@@ -0,0 +1,25 @@
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilValue } from 'recoil';
export const usePageLayoutIdFromContextStoreTargetedRecord = () => {
const targetedRecordsRule = useRecoilComponentValue(
contextStoreTargetedRecordsRuleComponentState,
);
if (
!(
targetedRecordsRule.mode === 'selection' &&
targetedRecordsRule.selectedRecordIds.length === 1
)
) {
throw new Error('Only one record should be selected');
}
const recordId: string = targetedRecordsRule.selectedRecordIds[0];
const record = useRecoilValue(recordStoreFamilyState(recordId));
return { pageLayoutId: record?.pageLayoutId };
};
@@ -0,0 +1,6 @@
import { type CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
export type PageLayoutCommandMenuPage =
| CommandMenuPages.PageLayoutWidgetTypeSelect
| CommandMenuPages.PageLayoutGraphTypeSelect
| CommandMenuPages.PageLayoutIframeConfig;
@@ -0,0 +1,17 @@
import { type PageLayoutCommandMenuPage } from '@/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { assertUnreachable } from 'twenty-shared/utils';
import { IconAppWindow, IconChartPie, IconFrame } from 'twenty-ui/display';
export const getPageLayoutIcon = (page: PageLayoutCommandMenuPage) => {
switch (page) {
case CommandMenuPages.PageLayoutWidgetTypeSelect:
return IconAppWindow;
case CommandMenuPages.PageLayoutGraphTypeSelect:
return IconChartPie;
case CommandMenuPages.PageLayoutIframeConfig:
return IconFrame;
default:
assertUnreachable(page);
}
};
@@ -0,0 +1,17 @@
import { type PageLayoutCommandMenuPage } from '@/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
export const getPageLayoutPageTitle = (page: PageLayoutCommandMenuPage) => {
switch (page) {
case CommandMenuPages.PageLayoutWidgetTypeSelect:
return t`Add Widget`;
case CommandMenuPages.PageLayoutGraphTypeSelect:
return t`Select Graph Type`;
case CommandMenuPages.PageLayoutIframeConfig:
return t`Configure iFrame`;
default:
assertUnreachable(page);
}
};
@@ -1,36 +1,22 @@
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';
import { isDefined } from 'twenty-shared/utils';
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 { record: dashboard } = useFindOneRecord<Dashboard>({
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: recordId,
});
const pageLayout = data?.getPageLayout;
if (!pageLayout) {
if (!isDefined(dashboard)) {
return null;
}
const normalizedPageLayout = normalizePageLayoutData(pageLayout);
return <PageLayoutRenderer pageLayout={normalizedPageLayout} />;
return <PageLayoutRenderer pageLayoutId={dashboard.pageLayoutId} />;
};
@@ -10,5 +10,6 @@ export const PAGE_LAYOUT_TAB_FRAGMENT = gql`
widgets {
...PageLayoutWidgetFragment
}
pageLayoutId
}
`;
@@ -13,5 +13,6 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
rowSpan
}
configuration
pageLayoutTabId
}
`;
@@ -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',
@@ -0,0 +1,32 @@
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 ReactNode } from 'react';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
export const PAGE_LAYOUT_TEST_INSTANCE_ID =
'20202020-f244-4ae0-906b-78958aa07642';
export const PageLayoutTestWrapper = ({
children,
initializeState,
instanceId: instanceIdFromProps,
}: {
children: ReactNode;
initializeState?: (snapshot: MutableSnapshot) => void;
instanceId?: string;
}) => {
const instanceId = instanceIdFromProps ?? PAGE_LAYOUT_TEST_INSTANCE_ID;
return (
<PageLayoutComponentInstanceContext.Provider value={{ instanceId }}>
<TabListComponentInstanceContext.Provider
value={{
instanceId: getTabListInstanceIdFromPageLayoutId(instanceId),
}}
>
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
);
};
@@ -1,139 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useChangePageLayoutDragSelection } from '../useChangePageLayoutDragSelection';
describe('useChangePageLayoutDragSelection', () => {
it('should add cell to selection when selected is true', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
true,
);
});
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
});
it('should remove cell from selection when selected is false', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
});
it('should handle adding same cell multiple times', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: RecoilRoot,
},
);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
});
it('should handle removing non-existent cell', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-99',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
});
it('should return a function', () => {
const { result } = renderHook(() => useChangePageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.changePageLayoutDragSelection).toBe(
'function',
);
});
});
@@ -0,0 +1,191 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { pageLayoutSelectedCellsComponentState } from '../../states/pageLayoutSelectedCellsComponentState';
import {
PageLayoutTestWrapper,
PAGE_LAYOUT_TEST_INSTANCE_ID,
} from './PageLayoutTestWrapper';
import { useChangePageLayoutDragSelection } from '../useChangePageLayoutDragSelection';
describe('useChangePageLayoutDragSelection', () => {
it('should add cell to selection when selected is true', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={({ set }) => {
set(
pageLayoutSelectedCellsComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
new Set(['cell-1']),
);
}}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
true,
);
});
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
});
it('should remove cell from selection when selected is false', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={({ set }) => {
set(
pageLayoutSelectedCellsComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
new Set(['cell-1', 'cell-2']),
);
}}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-2',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(false);
});
it('should handle adding same cell multiple times', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper>{children}</PageLayoutTestWrapper>
),
},
);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-1',
true,
);
});
expect(result.current.selectedCells.size).toBe(1);
});
it('should handle removing non-existent cell', () => {
const { result } = renderHook(
() => ({
changeDragSelection: useChangePageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={({ set }) => {
set(
pageLayoutSelectedCellsComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
new Set(['cell-1']),
);
}}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.changeDragSelection.changePageLayoutDragSelection(
'cell-99',
false,
);
});
expect(result.current.selectedCells.size).toBe(1);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
});
it('should return a function', () => {
const { result } = renderHook(
() => useChangePageLayoutDragSelection(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper>{children}</PageLayoutTestWrapper>
),
},
);
expect(typeof result.current.changePageLayoutDragSelection).toBe(
'function',
);
});
});
@@ -1,8 +1,12 @@
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCreatePageLayoutTab } from '../useCreatePageLayoutTab';
import {
PageLayoutTestWrapper,
PAGE_LAYOUT_TEST_INSTANCE_ID,
} from './PageLayoutTestWrapper';
jest.mock('uuid', () => ({
v4: jest.fn(),
@@ -18,12 +22,18 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
createTab: useCreatePageLayoutTab(PAGE_LAYOUT_TEST_INSTANCE_ID),
pageLayoutCurrentLayouts: useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
pageLayoutDraft: useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
@@ -50,11 +60,14 @@ describe('useCreatePageLayoutTab', () => {
uuidModule.v4.mockReturnValue('mock-uuid');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
createTab: useCreatePageLayoutTab(PAGE_LAYOUT_TEST_INSTANCE_ID),
pageLayoutDraft: useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
@@ -74,11 +87,14 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutDraft: useRecoilValue(pageLayoutDraftState),
createTab: useCreatePageLayoutTab(PAGE_LAYOUT_TEST_INSTANCE_ID),
pageLayoutDraft: useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
@@ -104,11 +120,14 @@ describe('useCreatePageLayoutTab', () => {
.mockReturnValueOnce('mock-uuid-2');
const { result } = renderHook(
() => ({
createTab: useCreatePageLayoutTab(),
pageLayoutCurrentLayouts: useRecoilValue(pageLayoutCurrentLayoutsState),
createTab: useCreatePageLayoutTab(PAGE_LAYOUT_TEST_INSTANCE_ID),
pageLayoutCurrentLayouts: useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
@@ -1,12 +1,17 @@
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 { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
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 { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { useCreatePageLayoutWidget } from '../useCreatePageLayoutWidget';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from './PageLayoutTestWrapper';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid'),
@@ -22,16 +27,25 @@ describe('useCreatePageLayoutWidget', () => {
() => {
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
instanceId: `${PAGE_LAYOUT_TEST_INSTANCE_ID}-tab-list`,
}),
);
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
const setPageLayoutDraft = useSetRecoilComponentState(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const pageLayoutDraft = useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget(
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget();
return {
setActiveTabId,
setPageLayoutDraft,
@@ -41,12 +55,13 @@ describe('useCreatePageLayoutWidget', () => {
};
},
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.setPageLayoutDraft({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
@@ -86,18 +101,27 @@ describe('useCreatePageLayoutWidget', () => {
it('should handle different graph types', () => {
const { result } = renderHook(
() => {
const setPageLayoutDraft = useSetRecoilState(pageLayoutDraftState);
const setPageLayoutDraft = useSetRecoilComponentState(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
instanceId: `${PAGE_LAYOUT_TEST_INSTANCE_ID}-tab-list`,
}),
);
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
const pageLayoutDraft = useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget(
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget();
return {
setPageLayoutDraft,
setActiveTabId,
@@ -108,12 +132,13 @@ describe('useCreatePageLayoutWidget', () => {
};
},
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.setPageLayoutDraft({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
@@ -173,16 +198,22 @@ describe('useCreatePageLayoutWidget', () => {
it('should not create widget when activeTabId is null', () => {
const { result } = renderHook(
() => {
const pageLayoutDraft = useRecoilValue(pageLayoutDraftState);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
const pageLayoutDraft = useRecoilComponentValue(
pageLayoutDraftComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const pageLayoutCurrentLayouts = useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget(
PAGE_LAYOUT_TEST_INSTANCE_ID,
);
const createWidget = useCreatePageLayoutWidget();
return { allWidgets, pageLayoutCurrentLayouts, createWidget };
},
{
wrapper: RecoilRoot,
wrapper: PageLayoutTestWrapper,
},
);
@@ -1,12 +1,18 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import {
PageLayoutTestWrapper,
PAGE_LAYOUT_TEST_INSTANCE_ID,
} from './PageLayoutTestWrapper';
import { useDeletePageLayoutWidget } from '../useDeletePageLayoutWidget';
describe('useDeletePageLayoutWidget', () => {
it('should remove widget from all states', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => useDeletePageLayoutWidget(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.deletePageLayoutWidget('widget-1');
@@ -16,9 +22,12 @@ describe('useDeletePageLayoutWidget', () => {
});
it('should handle removing non-existent widget', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => useDeletePageLayoutWidget(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.deletePageLayoutWidget('non-existent-widget');
@@ -28,9 +37,12 @@ describe('useDeletePageLayoutWidget', () => {
});
it('should handle empty layouts', () => {
const { result } = renderHook(() => useDeletePageLayoutWidget(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => useDeletePageLayoutWidget(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.deletePageLayoutWidget('any-widget');
@@ -1,208 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { calculateGridBoundsFromSelectedCells } from '../../utils/calculateGridBoundsFromSelectedCells';
import { useEndPageLayoutDragSelection } from '../useEndPageLayoutDragSelection';
jest.mock('@/command-menu/hooks/useNavigateCommandMenu');
jest.mock('../../utils/calculateGridBoundsFromSelectedCells');
describe('useEndPageLayoutDragSelection', () => {
const mockNavigateCommandMenu = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigateCommandMenu as jest.Mock).mockReturnValue({
navigateCommandMenu: mockNavigateCommandMenu,
});
});
it('should handle drag selection end with valid bounds', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(
pageLayoutSelectedCellsState,
new Set(['0-0', '0-1', '1-0', '1-1']),
);
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(4);
expect(result.current.draggedArea).toBeNull();
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'0-0',
'0-1',
'1-0',
'1-1',
]);
expect(result.current.draggedArea).toEqual(mockBounds);
expect(result.current.selectedCells.size).toBe(0);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
});
it('should not navigate when no cells are selected', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set());
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).not.toHaveBeenCalled();
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should not navigate when bounds calculation returns null', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
draggedArea: useRecoilValue(pageLayoutDraggedAreaState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['invalid-cell']));
set(pageLayoutDraggedAreaState, null);
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'invalid-cell',
]);
expect(mockNavigateCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(1);
});
it('should return a function', () => {
const { result } = renderHook(() => useEndPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.endPageLayoutDragSelection).toBe('function');
});
it('should navigate to widget selection when bounds are valid', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(mockNavigateCommandMenu).toHaveBeenCalled();
});
it('should clear selected cells after successful navigation', () => {
const mockBounds = { x: 0, y: 0, w: 1, h: 1 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['0-0']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -0,0 +1,271 @@
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutDraggedAreaComponentState } from '../../states/pageLayoutDraggedAreaComponentState';
import { pageLayoutSelectedCellsComponentState } from '../../states/pageLayoutSelectedCellsComponentState';
import { calculateGridBoundsFromSelectedCells } from '../../utils/calculateGridBoundsFromSelectedCells';
import { useEndPageLayoutDragSelection } from '../useEndPageLayoutDragSelection';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from './PageLayoutTestWrapper';
jest.mock(
'@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu',
);
jest.mock('../../utils/calculateGridBoundsFromSelectedCells');
const createInitializeState =
(initialSelectedCells?: Set<string>, initialDraggedArea?: any) =>
({ set }: { set: any }) => {
if (isDefined(initialSelectedCells)) {
set(
pageLayoutSelectedCellsComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
initialSelectedCells,
);
}
if (initialDraggedArea !== undefined) {
set(
pageLayoutDraggedAreaComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
initialDraggedArea,
);
}
};
describe('useEndPageLayoutDragSelection', () => {
const mockNavigatePageLayoutCommandMenu = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigatePageLayoutCommandMenu as jest.Mock).mockReturnValue({
navigatePageLayoutCommandMenu: mockNavigatePageLayoutCommandMenu,
});
});
it('should handle drag selection end with valid bounds', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
draggedArea: useRecoilComponentValue(
pageLayoutDraggedAreaComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(
new Set(['0-0', '0-1', '1-0', '1-1']),
null,
)}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(4);
expect(result.current.draggedArea).toBeNull();
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'0-0',
'0-1',
'1-0',
'1-1',
]);
expect(result.current.draggedArea).toEqual(mockBounds);
expect(result.current.selectedCells.size).toBe(0);
expect(mockNavigatePageLayoutCommandMenu).toHaveBeenCalledWith({
commandMenuPage: CommandMenuPages.PageLayoutWidgetTypeSelect,
});
});
it('should not navigate when no cells are selected', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
draggedArea: useRecoilComponentValue(
pageLayoutDraggedAreaComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(new Set(), null)}
>
{children}
</PageLayoutTestWrapper>
),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).not.toHaveBeenCalled();
expect(mockNavigatePageLayoutCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should not navigate when bounds calculation returns null', () => {
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(null);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
draggedArea: useRecoilComponentValue(
pageLayoutDraggedAreaComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(
new Set(['invalid-cell']),
null,
)}
>
{children}
</PageLayoutTestWrapper>
),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(calculateGridBoundsFromSelectedCells).toHaveBeenCalledWith([
'invalid-cell',
]);
expect(mockNavigatePageLayoutCommandMenu).not.toHaveBeenCalled();
expect(result.current.draggedArea).toBeNull();
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(
() => useEndPageLayoutDragSelection(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper>{children}</PageLayoutTestWrapper>
),
},
);
expect(typeof result.current.endPageLayoutDragSelection).toBe('function');
});
it('should navigate to widget selection when bounds are valid', () => {
const mockBounds = { x: 0, y: 0, w: 2, h: 2 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(new Set(['0-0']))}
>
{children}
</PageLayoutTestWrapper>
),
},
);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(mockNavigatePageLayoutCommandMenu).toHaveBeenCalled();
});
it('should clear selected cells after successful navigation', () => {
const mockBounds = { x: 0, y: 0, w: 1, h: 1 };
(calculateGridBoundsFromSelectedCells as jest.Mock).mockReturnValue(
mockBounds,
);
const { result } = renderHook(
() => ({
endDragSelection: useEndPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(new Set(['0-0']))}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.endDragSelection.endPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,26 +1,36 @@
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';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from './PageLayoutTestWrapper';
describe('usePageLayoutDraftState', () => {
it('should detect dirty state when draft differs from persisted', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => usePageLayoutDraftState(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
expect(result.current.isDirty).toBe(false);
expect(result.current.canSave).toBe(false);
});
it('should handle empty name as not saveable', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => usePageLayoutDraftState(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.setPageLayoutDraft({
id: 'test-layout',
name: ' ',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
@@ -33,12 +43,16 @@ describe('usePageLayoutDraftState', () => {
});
it('should allow updating draft state', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => usePageLayoutDraftState(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.setPageLayoutDraft({
id: 'test-layout',
name: 'Updated Name',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
@@ -52,12 +66,16 @@ describe('usePageLayoutDraftState', () => {
});
it('should detect changes in widgets', () => {
const { result } = renderHook(() => usePageLayoutDraftState(), {
wrapper: RecoilRoot,
});
const { result } = renderHook(
() => usePageLayoutDraftState(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: PageLayoutTestWrapper,
},
);
act(() => {
result.current.setPageLayoutDraft({
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
@@ -1,95 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutCurrentLayoutsState } from '../../states/pageLayoutCurrentLayoutsState';
import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange';
describe('usePageLayoutHandleLayoutChange', () => {
it('should update layouts for specific tab only', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange('tab-1'),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const newLayouts = {
desktop: [
{ i: 'widget-1', x: 2, y: 3, w: 4, h: 5 },
{ i: 'widget-2', x: 6, y: 7, w: 8, h: 9 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 5 },
{ i: 'widget-2', x: 0, y: 5, w: 1, h: 9 },
],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(result.current.layouts['tab-1']).toEqual(newLayouts);
expect(result.current.layouts['tab-2']).toBeUndefined();
});
it('should isolate layouts between different tabs', () => {
const { result, rerender } = renderHook(
({ tabId }) => ({
handler: usePageLayoutHandleLayoutChange(tabId),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
initialProps: { tabId: 'tab-1' },
},
);
const tab1Layouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], tab1Layouts);
});
rerender({ tabId: 'tab-2' });
const tab2Layouts = {
desktop: [{ i: 'widget-2', x: 4, y: 4, w: 3, h: 3 }],
mobile: [{ i: 'widget-2', x: 0, y: 0, w: 1, h: 3 }],
};
act(() => {
result.current.handler.handleLayoutChange([], tab2Layouts);
});
expect(result.current.layouts['tab-1']).toEqual(tab1Layouts);
expect(result.current.layouts['tab-2']).toEqual(tab2Layouts);
});
it('should not update layouts when activeTabId is null', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(null),
layouts: useRecoilValue(pageLayoutCurrentLayoutsState),
}),
{
wrapper: RecoilRoot,
},
);
const newLayouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(Object.keys(result.current.layouts)).toHaveLength(0);
});
});
@@ -0,0 +1,124 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { act, renderHook } from '@testing-library/react';
import { pageLayoutCurrentLayoutsComponentState } from '../../states/pageLayoutCurrentLayoutsComponentState';
import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from './PageLayoutTestWrapper';
describe('usePageLayoutHandleLayoutChange', () => {
it('should update layouts for specific tab only', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(PAGE_LAYOUT_TEST_INSTANCE_ID),
layouts: useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }) => (
<PageLayoutTestWrapper
initializeState={({ set }) => {
set(
activeTabIdComponentState.atomFamily({
instanceId: `${PAGE_LAYOUT_TEST_INSTANCE_ID}-tab-list`,
}),
'tab-1',
);
}}
>
{children}
</PageLayoutTestWrapper>
),
},
);
const newLayouts = {
desktop: [
{ i: 'widget-1', x: 2, y: 3, w: 4, h: 5 },
{ i: 'widget-2', x: 6, y: 7, w: 8, h: 9 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 5 },
{ i: 'widget-2', x: 0, y: 5, w: 1, h: 9 },
],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(result.current.layouts['tab-1']).toEqual(newLayouts);
expect(result.current.layouts['tab-2']).toBeUndefined();
});
it('should isolate layouts between different tabs', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(PAGE_LAYOUT_TEST_INSTANCE_ID),
layouts: useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }) => (
<PageLayoutTestWrapper
initializeState={({ set }) => {
set(
activeTabIdComponentState.atomFamily({
instanceId: `${PAGE_LAYOUT_TEST_INSTANCE_ID}-tab-list`,
}),
'tab-1',
);
}}
>
{children}
</PageLayoutTestWrapper>
),
},
);
const tab1Layouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], tab1Layouts);
});
// Unfortunately we can't properly test tab switching with the current renderHook API
// since we can't change the activeTabId after initialization in a simple way
expect(result.current.layouts['tab-1']).toEqual(tab1Layouts);
});
it('should not update layouts when activeTabId is null', () => {
const { result } = renderHook(
() => ({
handler: usePageLayoutHandleLayoutChange(PAGE_LAYOUT_TEST_INSTANCE_ID),
layouts: useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: PageLayoutTestWrapper,
},
);
const newLayouts = {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
mobile: [{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 }],
};
act(() => {
result.current.handler.handleLayoutChange([], newLayouts);
});
expect(Object.keys(result.current.layouts)).toHaveLength(0);
});
});
@@ -1,73 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { pageLayoutSelectedCellsState } from '../../states/pageLayoutSelectedCellsState';
import { useStartPageLayoutDragSelection } from '../useStartPageLayoutDragSelection';
describe('useStartPageLayoutDragSelection', () => {
it('should clear selected cells when starting drag selection', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1', 'cell-2']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(() => useStartPageLayoutDragSelection(), {
wrapper: RecoilRoot,
});
expect(typeof result.current.startPageLayoutDragSelection).toBe('function');
});
it('should handle multiple calls correctly', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(),
selectedCells: useRecoilValue(pageLayoutSelectedCellsState),
}),
{
wrapper: ({ children }: { children: ReactNode }) =>
RecoilRoot({
initializeState: ({ set }) => {
set(pageLayoutSelectedCellsState, new Set(['cell-1']));
},
children,
}),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -0,0 +1,108 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutSelectedCellsComponentState } from '../../states/pageLayoutSelectedCellsComponentState';
import { useStartPageLayoutDragSelection } from '../useStartPageLayoutDragSelection';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from './PageLayoutTestWrapper';
const createInitializeState =
(initialSelectedCells?: Set<string>) =>
({ set }: { set: any }) => {
if (isDefined(initialSelectedCells)) {
set(
pageLayoutSelectedCellsComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
}),
initialSelectedCells,
);
}
};
describe('useStartPageLayoutDragSelection', () => {
it('should clear selected cells when starting drag selection', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(
new Set(['cell-1', 'cell-2']),
)}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(2);
expect(result.current.selectedCells.has('cell-1')).toBe(true);
expect(result.current.selectedCells.has('cell-2')).toBe(true);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
it('should return a function', () => {
const { result } = renderHook(
() => useStartPageLayoutDragSelection(PAGE_LAYOUT_TEST_INSTANCE_ID),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper>{children}</PageLayoutTestWrapper>
),
},
);
expect(typeof result.current.startPageLayoutDragSelection).toBe('function');
});
it('should handle multiple calls correctly', () => {
const { result } = renderHook(
() => ({
startDragSelection: useStartPageLayoutDragSelection(
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
selectedCells: useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
PAGE_LAYOUT_TEST_INSTANCE_ID,
),
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
initializeState={createInitializeState(new Set(['cell-1']))}
>
{children}
</PageLayoutTestWrapper>
),
},
);
expect(result.current.selectedCells.size).toBe(1);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
act(() => {
result.current.startDragSelection.startPageLayoutDragSelection();
});
expect(result.current.selectedCells.size).toBe(0);
});
});
@@ -1,7 +1,22 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { pageLayoutSelectedCellsComponentState } from '../states/pageLayoutSelectedCellsComponentState';
export const useChangePageLayoutDragSelection = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutSelectedCellsState = useRecoilComponentCallbackState(
pageLayoutSelectedCellsComponentState,
pageLayoutId,
);
export const useChangePageLayoutDragSelection = () => {
const changePageLayoutDragSelection = useRecoilCallback(
({ set }) =>
(cellId: string, selected: boolean) => {
@@ -15,7 +30,7 @@ export const useChangePageLayoutDragSelection = () => {
return newSet;
});
},
[],
[pageLayoutSelectedCellsState],
);
return { changePageLayoutDragSelection };
@@ -1,21 +1,46 @@
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 { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
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 { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutIframeWidget = () => {
export const useCreatePageLayoutIframeWidget = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
getTabListInstanceIdFromPageLayoutId(pageLayoutId),
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const pageLayoutDraggedAreaState = useRecoilComponentCallbackState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const createPageLayoutIframeWidget = useRecoilCallback(
@@ -24,6 +49,7 @@ export const useCreatePageLayoutIframeWidget = () => {
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
@@ -68,11 +94,12 @@ export const useCreatePageLayoutIframeWidget = () => {
h: position.h,
};
const updatedLayouts = createUpdatedTabLayouts(
const updatedLayouts = getUpdatedTabLayouts(
allTabLayouts,
activeTabId,
newLayout,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
@@ -82,7 +109,12 @@ export const useCreatePageLayoutIframeWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[
activeTabId,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
pageLayoutDraggedAreaState,
],
);
return { createPageLayoutIframeWidget };
@@ -1,11 +1,29 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { type PageLayoutTabWithData } from '../types/pageLayoutTypes';
import { createEmptyTabLayout } from '../utils/createEmptyTabLayout';
import { getEmptyTabLayout } from '../utils/getEmptyTabLayout';
export const useCreatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
export const useCreatePageLayoutTab = () => {
const createPageLayoutTab = useRecoilCallback(
({ snapshot, set }) =>
(title?: string): string => {
@@ -34,12 +52,12 @@ export const useCreatePageLayoutTab = () => {
}));
set(pageLayoutCurrentLayoutsState, (prev) =>
createEmptyTabLayout(prev, newTabId),
getEmptyTabLayout(prev, newTabId),
);
return newTabId;
},
[],
[pageLayoutCurrentLayoutsState, pageLayoutDraftState],
);
return { createPageLayoutTab };
@@ -1,48 +1,76 @@
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 { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
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 '@/page-layout/utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { type WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutWidget = () => {
const activeTabId = useRecoilComponentValue(
export const useCreatePageLayoutWidget = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(pageLayoutId);
const activeTabIdState = useRecoilComponentCallbackState(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
tabListInstanceId,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const pageLayoutDraggedAreaState = useRecoilComponentCallbackState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const createPageLayoutWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetType: WidgetType, graphType: GraphType) => {
const activeTabId = snapshot.getLoadable(activeTabIdState).getValue();
if (!activeTabId) {
return;
}
const widgetData = getDefaultWidgetData(graphType);
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
if (!activeTabId) {
return;
}
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const existingWidgetCount = allWidgets.filter(
(w) =>
@@ -86,7 +114,7 @@ export const useCreatePageLayoutWidget = () => {
h: position.h,
};
const updatedLayouts = createUpdatedTabLayouts(
const updatedLayouts = getUpdatedTabLayouts(
allTabLayouts,
activeTabId,
newLayout,
@@ -100,7 +128,12 @@ export const useCreatePageLayoutWidget = () => {
set(pageLayoutDraggedAreaState, null);
},
[activeTabId],
[
activeTabIdState,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
pageLayoutDraggedAreaState,
],
);
return { createPageLayoutWidget };
@@ -0,0 +1,24 @@
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
export const useCurrentPageLayout = () => {
const pageLayoutPersisted = useRecoilComponentValue(
pageLayoutPersistedComponentState,
);
const pageLayoutDraft = useRecoilComponentValue(
pageLayoutDraftComponentState,
);
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
const currentPageLayout = isPageLayoutInEditMode
? pageLayoutDraft
: pageLayoutPersisted;
return { currentPageLayout };
};
@@ -1,11 +1,29 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { removeWidgetFromTab } from '../utils/removeWidgetFromTab';
import { removeWidgetLayoutFromTab } from '../utils/removeWidgetLayoutFromTab';
export const useDeletePageLayoutWidget = () => {
export const useDeletePageLayoutWidget = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const deletePageLayoutWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string) => {
@@ -35,7 +53,7 @@ export const useDeletePageLayoutWidget = () => {
}));
}
},
[],
[pageLayoutCurrentLayoutsState, pageLayoutDraftState],
);
return { deletePageLayoutWidget };
@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { t } from '@lingui/core/macro';
import { WidgetType } from '~/generated/graphql';
export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const setPageLayoutEditingWidgetId = useSetRecoilComponentState(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const handleEditWidget = useCallback(
({
widgetId,
widgetType,
}: {
widgetId: string;
widgetType: WidgetType;
}) => {
setPageLayoutEditingWidgetId(widgetId);
if (widgetType === WidgetType.IFRAME) {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutIframeConfig,
pageTitle: t`Edit iFrame`,
});
}
},
[setPageLayoutEditingWidgetId, navigatePageLayoutCommandMenu],
);
return {
handleEditWidget,
};
};
@@ -1,14 +1,32 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconAppWindow } from 'twenty-ui/display';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { pageLayoutSelectedCellsComponentState } from '../states/pageLayoutSelectedCellsComponentState';
import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells';
export const useEndPageLayoutDragSelection = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
export const useEndPageLayoutDragSelection = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutSelectedCellsState = useRecoilComponentCallbackState(
pageLayoutSelectedCellsComponentState,
pageLayoutId,
);
const pageLayoutDraggedAreaState = useRecoilComponentCallbackState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const endPageLayoutDragSelection = useRecoilCallback(
({ snapshot, set }) =>
@@ -25,18 +43,19 @@ export const useEndPageLayoutDragSelection = () => {
if (isDefined(draggedBounds)) {
set(pageLayoutDraggedAreaState, draggedBounds);
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutWidgetTypeSelect,
});
set(pageLayoutSelectedCellsState, new Set());
}
}
set(pageLayoutSelectedCellsState, new Set());
},
[navigateCommandMenu],
[
navigatePageLayoutCommandMenu,
pageLayoutDraggedAreaState,
pageLayoutSelectedCellsState,
],
);
return { endPageLayoutDragSelection };
@@ -1,15 +1,29 @@
import { useRecoilState, useRecoilValue } from 'recoil';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '../states/pageLayoutPersistedComponentState';
export const usePageLayoutDraftState = () => {
const [pageLayoutDraft, setPageLayoutDraft] =
useRecoilState(pageLayoutDraftState);
const pageLayoutPersisted = useRecoilValue(pageLayoutPersistedState);
export const usePageLayoutDraftState = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const [pageLayoutDraft, setPageLayoutDraft] = useRecoilComponentState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutPersisted = useRecoilComponentValue(
pageLayoutPersistedComponentState,
pageLayoutId,
);
const isDirty = pageLayoutPersisted
? !isDeeplyEqual(pageLayoutDraft, {
id: pageLayoutPersisted.id,
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
@@ -1,16 +1,47 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { type Layout, type Layouts } from 'react-grid-layout';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutWidgetWithData } from '../types/pageLayoutTypes';
import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets';
export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
export const usePageLayoutHandleLayoutChange = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(pageLayoutId);
const activeTabIdState = useRecoilComponentCallbackState(
activeTabIdComponentState,
tabListInstanceId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const handleLayoutChange = useRecoilCallback(
({ snapshot, set }) =>
(_: Layout[], allLayouts: Layouts) => {
const activeTabId = snapshot.getLoadable(activeTabIdState).getValue();
if (!isDefined(activeTabId)) return;
const currentTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
@@ -27,7 +58,9 @@ export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
const currentTab = pageLayoutDraft.tabs.find(
(tab) => tab.id === activeTabId,
);
if (!currentTab) return;
const updatedWidgets = convertLayoutsToWidgets(
currentTab.widgets,
allLayouts,
@@ -38,7 +71,7 @@ export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
...prev,
tabs: prev.tabs.map((tab) => {
if (tab.id === activeTabId) {
const tabWidgets: PageLayoutWidgetWithData[] = updatedWidgets
const tabWidgets = updatedWidgets
.filter((w) => w.pageLayoutTabId === activeTabId)
.map((widget) => ({
id: widget.id,
@@ -65,7 +98,7 @@ export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
}));
}
},
[activeTabId],
[activeTabIdState, pageLayoutCurrentLayoutsState, pageLayoutDraftState],
);
return { handleLayoutChange };
@@ -1,73 +0,0 @@
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';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
const savePageLayout = useRecoilCallback(
({ snapshot, set }) =>
async (widgetsWithPositions?: PageLayoutWidgetWithData[]) => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const savedPageLayouts = snapshot
.getLoadable(savedPageLayoutsState)
.getValue();
const existingLayout = isEditMode
? savedPageLayouts.find((layout) => layout.id === id)
: undefined;
const updatedTabs = widgetsWithPositions
? pageLayoutDraft.tabs.map((tab) => ({
...tab,
widgets: widgetsWithPositions.filter(
(w) => w.pageLayoutTabId === tab.id,
),
}))
: pageLayoutDraft.tabs;
const layoutToSave: PageLayoutWithData = {
id: isEditMode ? id : uuidv4(),
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
objectMetadataId: pageLayoutDraft.objectMetadataId,
tabs: updatedTabs,
createdAt: isEditMode
? (existingLayout?.createdAt ?? new Date().toISOString())
: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
set(savedPageLayoutsState, (prev) => {
if (isDefined(isEditMode)) {
return prev.map((layout) =>
layout.id === id ? layoutToSave : layout,
);
}
return [...prev, layoutToSave];
});
set(pageLayoutPersistedState, layoutToSave);
navigateSettings(SettingsPath.PageLayout);
},
[isEditMode, id, navigateSettings],
);
return { savePageLayout };
};
@@ -1,13 +1,28 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState';
import { pageLayoutSelectedCellsComponentState } from '../states/pageLayoutSelectedCellsComponentState';
export const useStartPageLayoutDragSelection = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutSelectedCellsState = useRecoilComponentCallbackState(
pageLayoutSelectedCellsComponentState,
pageLayoutId,
);
export const useStartPageLayoutDragSelection = () => {
const startPageLayoutDragSelection = useRecoilCallback(
({ set }) =>
() => {
set(pageLayoutSelectedCellsState, new Set());
},
[],
[pageLayoutSelectedCellsState],
);
return { startPageLayoutDragSelection };
@@ -1,8 +1,21 @@
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
export const useUpdatePageLayoutWidget = () => {
export const useUpdatePageLayoutWidget = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const updatePageLayoutWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidgetWithData>) => {
@@ -16,7 +29,7 @@ export const useUpdatePageLayoutWidget = () => {
})),
}));
},
[],
[pageLayoutDraftState],
);
return { updatePageLayoutWidget };
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const PageLayoutComponentInstanceContext =
createComponentInstanceContext();
@@ -0,0 +1,10 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const isPageLayoutInEditModeComponentState =
createComponentState<boolean>({
key: 'isPageLayoutInEditModeComponentState',
defaultValue: false,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type PageLayoutBreakpoint } from '../constants/PageLayoutBreakpoints';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutCurrentBreakpointComponentState =
createComponentState<PageLayoutBreakpoint>({
key: 'pageLayoutCurrentBreakpointComponentState',
defaultValue: 'desktop',
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,8 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type PageLayoutBreakpoint } from '../constants/PageLayoutBreakpoints';
export const pageLayoutCurrentBreakpointState =
createState<PageLayoutBreakpoint>({
key: 'pageLayoutCurrentBreakpointState',
defaultValue: 'desktop',
});
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutCurrentLayoutsComponentState =
createComponentState<TabLayouts>({
key: 'pageLayoutCurrentLayoutsComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,9 +0,0 @@
import { type Layouts } from 'react-grid-layout';
import { createState } from 'twenty-ui/utilities';
export type TabLayouts = Record<string, Layouts>;
export const pageLayoutCurrentLayoutsState = createState<TabLayouts>({
key: 'pageLayoutCurrentLayoutsState',
defaultValue: {},
});
@@ -0,0 +1,18 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutType } from '~/generated/graphql';
import { type DraftPageLayout } from '@/page-layout/types/draft-page-layout';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutDraftComponentState =
createComponentState<DraftPageLayout>({
key: 'pageLayoutDraftComponentState',
defaultValue: {
id: '',
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [],
},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,18 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { PageLayoutType } from '~/generated/graphql';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export type DraftPageLayout = Omit<
PageLayoutWithData,
'id' | 'createdAt' | 'updatedAt' | 'deletedAt'
>;
export const pageLayoutDraftState = createState<DraftPageLayout>({
key: 'pageLayoutDraftState',
defaultValue: {
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [],
},
});
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type PageLayoutDraggedArea } from '@/page-layout/types/page-layout-dragged-area';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutDraggedAreaComponentState =
createComponentState<PageLayoutDraggedArea>({
key: 'pageLayoutDraggedAreaComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,13 +0,0 @@
import { createState } from 'twenty-ui/utilities';
type DraggedArea = {
x: number;
y: number;
w: number;
h: number;
} | null;
export const pageLayoutDraggedAreaState = createState<DraggedArea>({
key: 'pageLayoutDraggedAreaState',
defaultValue: null,
});
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutEditingWidgetIdComponentState = createComponentState<
string | null
>({
key: 'pageLayoutEditingWidgetIdComponentState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutEditingWidgetIdState = createState<string | null>({
key: 'pageLayoutEditingWidgetIdState',
defaultValue: null,
});
@@ -0,0 +1,12 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutPersistedComponentState = createComponentState<
PageLayoutWithData | undefined
>({
key: 'pageLayoutPersistedComponentState',
defaultValue: undefined,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,9 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const pageLayoutPersistedState = createState<
PageLayoutWithData | undefined
>({
key: 'pageLayoutPersistedState',
defaultValue: undefined,
});
@@ -0,0 +1,11 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const pageLayoutSelectedCellsComponentState = createComponentState<
Set<string>
>({
key: 'pageLayoutSelectedCellsComponentState',
defaultValue: new Set(),
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutSelectedCellsState = createState<Set<string>>({
key: 'pageLayoutSelectedCellsState',
defaultValue: new Set(),
});
@@ -0,0 +1,12 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const savedPageLayoutsComponentState = createComponentState<
PageLayoutWithData[]
>({
key: 'savedPageLayoutsComponentState',
defaultValue: [],
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,7 +0,0 @@
import { createState } from 'twenty-ui/utilities';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const savedPageLayoutsState = createState<PageLayoutWithData[]>({
key: 'savedPageLayoutsState',
defaultValue: [],
});
@@ -0,0 +1,6 @@
import { type PageLayoutWithData } from './pageLayoutTypes';
export type DraftPageLayout = Omit<
PageLayoutWithData,
'createdAt' | 'updatedAt' | 'deletedAt'
>;
@@ -0,0 +1,6 @@
export type PageLayoutDraggedArea = {
x: number;
y: number;
w: number;
h: number;
} | null;
@@ -0,0 +1,3 @@
import { type Layouts } from 'react-grid-layout';
export type TabLayouts = Record<string, Layouts>;
@@ -1,7 +1,7 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createEmptyTabLayout } from '../createEmptyTabLayout';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { getEmptyTabLayout } from '../getEmptyTabLayout';
describe('createEmptyTabLayout', () => {
describe('getEmptyTabLayout', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
@@ -14,7 +14,7 @@ describe('createEmptyTabLayout', () => {
};
it('should create empty layout for a new tab', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
const result = getEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toEqual([]);
@@ -22,7 +22,7 @@ describe('createEmptyTabLayout', () => {
});
it('should preserve existing tabs', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
const result = getEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result['tab-1']).toEqual(mockTabLayouts['tab-1']);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
@@ -30,7 +30,7 @@ describe('createEmptyTabLayout', () => {
});
it('should overwrite existing tab with empty layout', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'tab-1');
const result = getEmptyTabLayout(mockTabLayouts, 'tab-1');
expect(result['tab-1'].desktop).toEqual([]);
expect(result['tab-1'].mobile).toEqual([]);
@@ -39,7 +39,7 @@ describe('createEmptyTabLayout', () => {
it('should work with empty initial state', () => {
const emptyLayouts: TabLayouts = {};
const result = createEmptyTabLayout(emptyLayouts, 'tab-1');
const result = getEmptyTabLayout(emptyLayouts, 'tab-1');
expect(result['tab-1']).toBeDefined();
expect(result['tab-1'].desktop).toEqual([]);
@@ -49,7 +49,7 @@ describe('createEmptyTabLayout', () => {
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
const result = getEmptyTabLayout(mockTabLayouts, 'tab-3');
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
@@ -58,9 +58,9 @@ describe('createEmptyTabLayout', () => {
});
it('should handle multiple new tabs', () => {
let result = createEmptyTabLayout(mockTabLayouts, 'tab-3');
result = createEmptyTabLayout(result, 'tab-4');
result = createEmptyTabLayout(result, 'tab-5');
let result = getEmptyTabLayout(mockTabLayouts, 'tab-3');
result = getEmptyTabLayout(result, 'tab-4');
result = getEmptyTabLayout(result, 'tab-5');
expect(Object.keys(result)).toHaveLength(5);
expect(result['tab-3'].desktop).toEqual([]);
@@ -69,7 +69,7 @@ describe('createEmptyTabLayout', () => {
});
it('should create consistent structure for desktop and mobile', () => {
const result = createEmptyTabLayout(mockTabLayouts, 'new-tab');
const result = getEmptyTabLayout(mockTabLayouts, 'new-tab');
expect(result['new-tab']).toHaveProperty('desktop');
expect(result['new-tab']).toHaveProperty('mobile');
@@ -1,7 +1,7 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { createUpdatedTabLayouts } from '../createUpdatedTabLayouts';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { getUpdatedTabLayouts } from '../getUpdatedTabLayouts';
describe('createUpdatedTabLayouts', () => {
describe('getUpdatedTabLayouts', () => {
const mockTabLayouts: TabLayouts = {
'tab-1': {
desktop: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }],
@@ -16,7 +16,7 @@ describe('createUpdatedTabLayouts', () => {
const newLayout = { i: 'widget-2', x: 2, y: 0, w: 3, h: 3 };
it('should add new layout to existing tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-1'].desktop).toHaveLength(2);
expect(result['tab-1'].desktop[1]).toEqual(newLayout);
@@ -25,7 +25,7 @@ describe('createUpdatedTabLayouts', () => {
});
it('should add layout to empty tab', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-2', newLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-2', newLayout);
expect(result['tab-2'].desktop).toHaveLength(1);
expect(result['tab-2'].desktop[0]).toEqual(newLayout);
@@ -34,7 +34,7 @@ describe('createUpdatedTabLayouts', () => {
});
it('should create new tab entry if tab does not exist', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-3', newLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-3', newLayout);
expect(result['tab-3']).toBeDefined();
expect(result['tab-3'].desktop).toHaveLength(1);
@@ -43,14 +43,14 @@ describe('createUpdatedTabLayouts', () => {
});
it('should not modify other tabs', () => {
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result['tab-2']).toEqual(mockTabLayouts['tab-2']);
});
it('should handle mobile layout transformation correctly', () => {
const wideLayout = { i: 'widget-3', x: 5, y: 2, w: 6, h: 4 };
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', wideLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-1', wideLayout);
const mobileLayout =
result['tab-1'].mobile[result['tab-1'].mobile.length - 1];
@@ -62,7 +62,7 @@ describe('createUpdatedTabLayouts', () => {
it('should return a new object without mutating the original', () => {
const originalLayouts = JSON.parse(JSON.stringify(mockTabLayouts));
const result = createUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
const result = getUpdatedTabLayouts(mockTabLayouts, 'tab-1', newLayout);
expect(result).not.toBe(mockTabLayouts);
expect(mockTabLayouts).toEqual(originalLayouts);
@@ -76,7 +76,7 @@ describe('createUpdatedTabLayouts', () => {
};
expect(() => {
createUpdatedTabLayouts(malformedLayouts, 'tab-1', newLayout);
getUpdatedTabLayouts(malformedLayouts, 'tab-1', newLayout);
}).toThrow();
});
});
@@ -1,4 +1,4 @@
import { type TabLayouts } from '../../states/pageLayoutCurrentLayoutsState';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { removeWidgetLayoutFromTab } from '../removeWidgetLayoutFromTab';
describe('removeWidgetLayoutFromTab', () => {
@@ -0,0 +1,12 @@
export const calculateGridCellPosition = ({
index,
numberOfColumns,
}: {
index: number;
numberOfColumns: number;
}) => {
const column = index % numberOfColumns;
const row = Math.floor(index / numberOfColumns);
return { row, column };
};
@@ -9,6 +9,7 @@ export const convertLayoutsToWidgets = (
return widgets.map((widget) => {
const layout = activeLayouts.find((l) => l.i === widget.id);
return {
...widget,
gridPosition: {
@@ -1,6 +1,6 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
export const createEmptyTabLayout = (
export const getEmptyTabLayout = (
allTabLayouts: TabLayouts,
tabId: string,
): TabLayouts => {
@@ -0,0 +1,3 @@
export const getTabListInstanceIdFromPageLayoutId = (pageLayoutId: string) => {
return `${pageLayoutId}-tab-list`;
};
@@ -1,6 +1,6 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
export const createUpdatedTabLayouts = (
export const getUpdatedTabLayouts = (
allTabLayouts: TabLayouts,
activeTabId: string,
newLayout: { i: string; x: number; y: number; w: number; h: number },
@@ -1,17 +0,0 @@
import { type PageLayout } from '~/generated/graphql';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const normalizePageLayoutData = (
pageLayout: PageLayout,
): PageLayoutWithData => {
return {
...pageLayout,
tabs: (pageLayout.tabs || []).map((tab) => ({
...tab,
widgets: (tab.widgets || []).map((widget) => ({
...widget,
data: undefined,
})),
})),
};
};
@@ -1,4 +1,4 @@
import { type TabLayouts } from '../states/pageLayoutCurrentLayoutsState';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
export const removeWidgetLayoutFromTab = (
allTabLayouts: TabLayouts,
@@ -28,7 +28,7 @@ const StyledTitle = styled.span`
`;
type WidgetHeaderProps = {
displayDragHandle: boolean;
isInEditMode: boolean;
isEmpty?: boolean;
title: string;
onRemove?: () => void;
@@ -36,15 +36,15 @@ type WidgetHeaderProps = {
};
export const WidgetHeader = ({
displayDragHandle = false,
isEmpty = false,
isInEditMode = false,
title,
onEdit,
onRemove,
}: WidgetHeaderProps) => {
return (
<StyledHeader>
{displayDragHandle && (
{isInEditMode && (
<StyledDragHandleButton
Icon={IconGripVertical}
className="drag-handle"
@@ -54,7 +54,7 @@ export const WidgetHeader = ({
/>
)}
<StyledTitle>{isEmpty ? 'Add Widget' : title}</StyledTitle>
{!isEmpty && onEdit && (
{!isEmpty && isInEditMode && onEdit && (
<IconButton
onClick={onEdit}
Icon={IconPencil}
@@ -62,7 +62,7 @@ export const WidgetHeader = ({
size="small"
/>
)}
{!isEmpty && onRemove && (
{!isEmpty && isInEditMode && onRemove && (
<IconButton
onClick={onRemove}
Icon={IconX}
@@ -16,7 +16,7 @@ type WidgetPlaceholderProps = {
export const WidgetPlaceholder = ({ onClick }: WidgetPlaceholderProps) => {
return (
<WidgetContainer onClick={onClick}>
<WidgetHeader displayDragHandle={true} title="Add Widget" isEmpty />
<WidgetHeader isInEditMode={true} title="Add Widget" isEmpty />
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
@@ -1,14 +1,15 @@
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWidget';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
type WidgetRendererProps = {
widget: WidgetType;
displayDragHandle?: boolean;
onEdit?: () => void;
onRemove?: () => void;
};
const StyledContent = styled.div`
@@ -19,19 +20,24 @@ const StyledContent = styled.div`
justify-content: center;
`;
export const WidgetRenderer = ({
widget,
displayDragHandle = false,
onEdit,
onRemove,
}: WidgetRendererProps) => {
export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { handleEditWidget } = useEditPageLayoutWidget();
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
widget.id,
);
return (
<WidgetContainer>
<WidgetHeader
displayDragHandle={displayDragHandle}
isInEditMode={isPageLayoutInEditMode}
title={widget.title}
onEdit={onEdit}
onRemove={onRemove}
onEdit={() =>
handleEditWidget({ widgetId: widget.id, widgetType: widget.type })
}
onRemove={() => deletePageLayoutWidget(widget.id)}
/>
<StyledContent>
<WidgetContentRenderer widget={widget} />
@@ -1,3 +1,4 @@
import { PageLayoutTestWrapper } from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper';
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { type Meta, type StoryObj } from '@storybook/react';
@@ -7,7 +8,12 @@ import { WidgetType } from '~/generated/graphql';
const meta: Meta<typeof WidgetRenderer> = {
title: 'Modules/PageLayout/Widgets/WidgetRenderer',
component: WidgetRenderer,
decorators: [ComponentDecorator],
decorators: [
(Story, context) => (
<PageLayoutTestWrapper>{Story(context)}</PageLayoutTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
},
@@ -16,16 +22,6 @@ const meta: Meta<typeof WidgetRenderer> = {
control: 'object',
description: 'Widget',
},
displayDragHandle: {
control: 'boolean',
description: 'Display drag handle',
},
onRemove: {
action: 'onRemove',
},
onEdit: {
action: 'onEdit',
},
},
};
@@ -55,16 +51,10 @@ export const WithNumberChart: Story = {
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}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -97,12 +87,7 @@ export const WithGaugeChart: Story = {
},
render: (args) => (
<div style={{ width: '300px', height: '400px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -163,12 +148,7 @@ export const WithPieChart: Story = {
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -206,12 +186,7 @@ export const SmallWidget: Story = {
},
render: (args) => (
<div style={{ width: '300px', height: '100px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -251,12 +226,7 @@ export const MediumWidget: Story = {
},
render: (args) => (
<div style={{ width: '400px', height: '250px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -298,12 +268,7 @@ export const LargeWidget: Story = {
},
render: (args) => (
<div style={{ width: '600px', height: '400px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -341,12 +306,7 @@ export const WideWidget: Story = {
},
render: (args) => (
<div style={{ width: '800px', height: '200px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -386,12 +346,7 @@ export const TallWidget: Story = {
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
<WidgetRenderer widget={args.widget} />
</div>
),
};
@@ -1,29 +0,0 @@
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import {
Breadcrumb,
type BreadcrumbProps,
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { type ReactNode } from 'react';
type SettingsPageFullWidthContainerProps = {
children: ReactNode;
links: BreadcrumbProps['links'];
actionButton?: ReactNode;
};
export const SettingsPageFullWidthContainer = ({
children,
links,
actionButton,
}: SettingsPageFullWidthContainerProps) => {
return (
<PageContainer>
<PageHeader title={<Breadcrumb links={links} />}>
{actionButton}
</PageHeader>
<PageBody>{children}</PageBody>
</PageContainer>
);
};
@@ -20,7 +20,6 @@ import {
IconFunction,
IconHierarchy2,
IconKey,
IconLayout,
IconLock,
IconMail,
IconRocket,
@@ -64,9 +63,6 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
(currentUser?.canImpersonate || currentUser?.canAccessFullAdminPanel) ??
false;
const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const isPageLayoutEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED,
);
const permissionMap = usePermissionFlagMap();
return [
@@ -144,15 +140,6 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden:
!isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Page Layouts`,
path: SettingsPath.PageLayout,
Icon: IconLayout,
isHidden:
!isPageLayoutEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
isNew: true,
},
{
label: t`APIs & Webhooks`,
path: SettingsPath.ApiWebhooks,
@@ -17,9 +17,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { Global, css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { Outlet, useLocation } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Outlet } from 'react-router-dom';
import { useScreenSize } from 'twenty-ui/utilities';
const StyledLayout = styled.div`
@@ -61,12 +59,6 @@ const StyledMainContainer = styled.div`
export const DefaultLayout = () => {
const isMobile = useIsMobile();
const isSettingsPage = useIsSettingsPage();
const location = useLocation();
const isPageLayoutEditor =
location.pathname.includes(getSettingsPath(SettingsPath.PageLayoutNew)) ||
location.pathname.match(
new RegExp(`${getSettingsPath(SettingsPath.PageLayout)}/[^/]+$`),
);
const theme = useTheme();
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
@@ -86,10 +78,7 @@ export const DefaultLayout = () => {
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage &&
!isMobile &&
!useShowFullScreen &&
!isPageLayoutEditor
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
@@ -1,405 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
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 { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { useChangePageLayoutDragSelection } from '@/page-layout/hooks/useChangePageLayoutDragSelection';
import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutTab';
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useEndPageLayoutDragSelection } from '@/page-layout/hooks/useEndPageLayoutDragSelection';
import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState';
import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange';
import { usePageLayoutSaveHandler } from '@/page-layout/hooks/usePageLayoutSaveHandler';
import { useStartPageLayoutDragSelection } from '@/page-layout/hooks/useStartPageLayoutDragSelection';
import { WidgetType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentBreakpointState } from '@/page-layout/states/pageLayoutCurrentBreakpointState';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutEditingWidgetIdState } from '@/page-layout/states/pageLayoutEditingWidgetIdState';
import { pageLayoutSelectedCellsState } from '@/page-layout/states/pageLayoutSelectedCellsState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { calculateTotalGridRows } from '@/page-layout/utils/calculateTotalGridRows';
import { generateCellId } from '@/page-layout/utils/generateCellId';
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 { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageFullWidthContainer } from '@/settings/components/SettingsPageFullWidthContainer';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useMemo, useRef, useState } 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 { useParams } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconAppWindow, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
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;
}
`;
const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
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 StyledActionButtonContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
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};
}
`;
type ExtendedResponsiveProps = ResponsiveProps & {
maxCols?: number;
preventCollision?: boolean;
};
const ResponsiveGridLayout = WidthProvider(
Responsive,
) as React.ComponentType<ExtendedResponsiveProps>;
export const SettingsPageLayoutEdit = () => {
const { t } = useLingui();
const { id } = useParams<{ id: string }>();
const isEditMode = id && id !== 'new';
const { pageLayoutDraft, setPageLayoutDraft, isDirty } =
usePageLayoutDraftState();
const { savePageLayout } = usePageLayoutSaveHandler();
const navigateSettings = useNavigateSettings();
const [isSaving, setIsSaving] = useState(false);
const [pageLayoutCurrentBreakpoint, setPageLayoutCurrentBreakpoint] =
useRecoilState(pageLayoutCurrentBreakpointState);
const pageLayoutSelectedCells = useRecoilValue(pageLayoutSelectedCellsState);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const { navigateCommandMenu } = useNavigateCommandMenu();
const setPageLayoutEditingWidgetId = useSetRecoilState(
pageLayoutEditingWidgetIdState,
);
const gridContainerRef = useRef<HTMLDivElement>(null);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
);
const setActiveTabId = useSetRecoilState(
activeTabIdComponentState.atomFamily({
instanceId: SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID,
}),
);
const activeTabWidgets = useMemo(() => {
if (!activeTabId) return [];
const activeTab = pageLayoutDraft.tabs.find(
(tab) => tab.id === activeTabId,
);
return activeTab?.widgets ?? [];
}, [pageLayoutDraft.tabs, activeTabId]);
const allWidgets = useMemo(
() => pageLayoutDraft.tabs.flatMap((tab) => tab.widgets).filter(isDefined),
[pageLayoutDraft.tabs],
);
const { startPageLayoutDragSelection } = useStartPageLayoutDragSelection();
const { changePageLayoutDragSelection } = useChangePageLayoutDragSelection();
const { endPageLayoutDragSelection } = useEndPageLayoutDragSelection();
const handleOpenAddWidget = useCallback(() => {
navigateCommandMenu({
page: CommandMenuPages.PageLayoutWidgetTypeSelect,
pageTitle: 'Add Widget',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
}, [navigateCommandMenu]);
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { handleLayoutChange } = usePageLayoutHandleLayoutChange(activeTabId);
const { createPageLayoutTab } = useCreatePageLayoutTab();
const handleEditWidget = useCallback(
(widgetId: string) => {
const widget = allWidgets.find((w) => w.id === widgetId);
if (!widget) return;
setPageLayoutEditingWidgetId(widgetId);
if (widget.type === WidgetType.IFRAME) {
navigateCommandMenu({
page: CommandMenuPages.PageLayoutIframeConfig,
pageTitle: 'Edit iFrame',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
}
},
[allWidgets, setPageLayoutEditingWidgetId, navigateCommandMenu],
);
const isEmptyState = activeTabWidgets.length === 0;
const gridRows = useMemo(() => {
const currentTabLayouts = pageLayoutCurrentLayouts[activeTabId || ''] || {
desktop: [],
mobile: [],
};
return calculateTotalGridRows(currentTabLayouts);
}, [pageLayoutCurrentLayouts, activeTabId]);
const handleCancel = () => {
navigateSettings(SettingsPath.PageLayout);
};
const handleAddTab = useCallback(() => {
const newTabId = createPageLayoutTab();
setActiveTabId(newTabId);
}, [createPageLayoutTab, setActiveTabId]);
const tabListTabs: SingleTabProps[] = useMemo(() => {
return [...(pageLayoutDraft.tabs ?? [])]
.sort((a, b) => a.position - b.position)
.map((tab) => ({
id: tab.id,
title: tab.title,
}));
}, [pageLayoutDraft.tabs]);
const handleSaveClick = async () => {
setIsSaving(true);
try {
const allWidgets: PageLayoutWidgetWithData[] =
pageLayoutDraft.tabs.flatMap((tab) =>
tab.widgets.map((widget) => ({
...widget,
pageLayoutTabId: widget.pageLayoutTabId ?? tab.id,
objectMetadataId: widget.objectMetadataId || null,
createdAt: widget.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: widget.deletedAt ?? null,
})),
);
setPageLayoutDraft((prev) => ({
...prev,
tabs: pageLayoutDraft.tabs,
}));
await savePageLayout(allWidgets);
} finally {
setIsSaving(false);
}
};
return (
<>
<PageLayoutInitializationEffect layoutId={id} isEditMode={!!isEditMode} />
<SettingsPageFullWidthContainer
links={[
{
children: t`Settings`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Page Layouts`,
href: getSettingsPath(SettingsPath.PageLayout),
},
{
children: (
<TitleInput
instanceId="page-layout-name-input"
placeholder={t`Layout Name`}
value={pageLayoutDraft.name}
onChange={(value) =>
setPageLayoutDraft((prev) => ({ ...prev, name: value }))
}
sizeVariant="md"
/>
),
},
]}
actionButton={
<StyledActionButtonContainer>
<SaveAndCancelButtons
onSave={handleSaveClick}
onCancel={handleCancel}
isLoading={isSaving}
isSaveDisabled={
!isDirty ||
!pageLayoutDraft.name.trim() ||
allWidgets.length === 0
}
/>
{!isEmptyState && (
<Button
Icon={IconPlus}
title={t`Add Widget`}
size="small"
variant="secondary"
onClick={handleOpenAddWidget}
/>
)}
</StyledActionButtonContainer>
}
>
{pageLayoutDraft.tabs.length > 0 && (
<StyledTabList
tabs={tabListTabs}
behaveAsLinks={false}
componentInstanceId={SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID}
onAddTab={handleAddTab}
/>
)}
<StyledGridContainer ref={gridContainerRef}>
<StyledGridOverlay
isDragSelecting={pageLayoutCurrentBreakpoint !== 'mobile'}
breakpoint={pageLayoutCurrentBreakpoint}
>
{Array.from({
length:
(pageLayoutCurrentBreakpoint === 'mobile' ? 1 : 12) * gridRows,
}).map((_, i) => {
const cols = pageLayoutCurrentBreakpoint === 'mobile' ? 1 : 12;
const col = i % cols;
const row = Math.floor(i / cols);
const cellId = generateCellId(col, row);
return (
<StyledGridCell
key={i}
data-selectable-id={cellId}
isSelected={pageLayoutSelectedCells.has(cellId)}
/>
);
})}
</StyledGridOverlay>
<ResponsiveGridLayout
className="layout"
layouts={
isEmptyState || !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={pageLayoutCurrentBreakpoint !== 'mobile'}
isResizable={pageLayoutCurrentBreakpoint !== 'mobile'}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
onLayoutChange={handleLayoutChange}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{isEmptyState ? (
<div key="empty-placeholder" data-select-disable="true">
<WidgetPlaceholder onClick={handleOpenAddWidget} />
</div>
) : (
activeTabWidgets.map((widget) => (
<div key={widget.id} data-select-disable="true">
<WidgetRenderer
widget={widget as Widget}
displayDragHandle={true}
onRemove={() => deletePageLayoutWidget(widget.id)}
onEdit={() => handleEditWidget(widget.id)}
/>
</div>
))
)}
</ResponsiveGridLayout>
{pageLayoutCurrentBreakpoint !== 'mobile' && (
<DragSelect
selectableItemsContainerRef={gridContainerRef}
onDragSelectionStart={startPageLayoutDragSelection}
onDragSelectionChange={changePageLayoutDragSelection}
onDragSelectionEnd={endPageLayoutDragSelection}
/>
)}
</StyledGridContainer>
</SettingsPageFullWidthContainer>
</>
);
};
@@ -1,138 +0,0 @@
import { savedPageLayoutsState } from '@/page-layout/states/savedPageLayoutsState';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconChevronRight,
IconLayoutList,
IconPlus,
IconTrashX,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 1fr 180px 80px 80px 36px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledActionTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledIconDelete = styled(IconTrashX)`
color: ${({ theme }) => theme.font.color.danger};
`;
export const SettingsPageLayouts = () => {
const { t } = useLingui();
const theme = useTheme();
const [savedPageLayouts, setSavedPageLayouts] = useRecoilState(
savedPageLayoutsState,
);
const handleDelete = (id: string) => {
setSavedPageLayouts((prev) => prev.filter((layout) => layout.id !== id));
};
return (
<SubMenuTopBarContainer
title={t`Page Layouts`}
actionButton={
<UndecoratedLink to={getSettingsPath(SettingsPath.PageLayoutNew)}>
<Button
Icon={IconPlus}
title={t`Add layout`}
accent="blue"
size="small"
/>
</UndecoratedLink>
}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Page Layouts</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title={t`Existing layouts`} />
<Table>
<StyledTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader>{t`Tabs`}</TableHeader>
<TableHeader>{t`Widgets`}</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
{savedPageLayouts.map((layout) => (
<StyledTableRow
key={layout.id}
to={getSettingsPath(SettingsPath.PageLayoutEdit, {
id: layout.id,
})}
>
<StyledNameTableCell>
<IconLayoutList
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
{layout.name}
</StyledNameTableCell>
<TableCell>{layout.type}</TableCell>
<TableCell>{layout.tabs.length}</TableCell>
<TableCell>
{layout.tabs.reduce(
(total, tab) => total + tab.widgets.length,
0,
)}
</TableCell>
<StyledActionTableCell
onClick={(event) => {
event.stopPropagation();
handleDelete(layout.id);
}}
>
<StyledIconDelete
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledActionTableCell>
<StyledActionTableCell>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledActionTableCell>
</StyledTableRow>
))}
</Table>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -54,7 +54,4 @@ export enum SettingsPath {
RoleDetail = 'roles/:roleId',
RoleObjectLevel = 'roles/:roleId/object/:objectMetadataId',
RoleAddObjectLevel = 'roles/:roleId/add-object-permission',
PageLayout = 'page-layout',
PageLayoutNew = 'page-layout/new',
PageLayoutEdit = 'page-layout/:id',
}