diff --git a/packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png b/packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png new file mode 100644 index 0000000000..3df7d4c639 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png b/packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png new file mode 100644 index 0000000000..d247ddbb71 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png b/packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png new file mode 100644 index 0000000000..ee6278c35e Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png differ diff --git a/packages/twenty-front/public/images/placeholders/moving-image/no_widgets.png b/packages/twenty-front/public/images/placeholders/moving-image/no_widgets.png new file mode 100644 index 0000000000..2be31e3eb0 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/moving-image/no_widgets.png differ diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx index dfdcb566fc..d9059c70b9 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx @@ -4,6 +4,7 @@ import { ActionMenuContextProviderWorkflowObjects } from '@/action-menu/contexts import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -13,8 +14,10 @@ export const ActionMenuContextProvider = ({ isInRightDrawer, displayType, actionMenuType, + objectMetadataItemOverride, }: Omit & { children: React.ReactNode; + objectMetadataItemOverride?: ObjectMetadataItem; }) => { const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue( contextStoreCurrentObjectMetadataItemIdComponentState, @@ -22,10 +25,12 @@ export const ActionMenuContextProvider = ({ const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const objectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId, - ); + const objectMetadataItem = + objectMetadataItemOverride ?? + objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId, + ); if (!isDefined(objectMetadataItem)) { return null; diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index c5ebc2815b..c1845fa131 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -1,7 +1,9 @@ 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 @@ -13,9 +15,17 @@ export const AppRouter = () => { (currentUser?.canImpersonate || currentUser?.canAccessFullAdminPanel) ?? false; + const isPageLayoutFeatureFlagEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, + ); + return ( ); }; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 7a49e0414e..0f207290d6 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -287,12 +287,20 @@ const SettingsObjectFieldEdit = lazy(() => ), ); -const PageLayoutEdition = lazy(() => - import('~/pages/settings/page-layout/PageLayoutEdition').then((module) => ({ - default: module.PageLayoutEdition, +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, @@ -378,11 +386,13 @@ const SettingsRoleAddObjectLevel = lazy(() => type SettingsRoutesProps = { isFunctionSettingsEnabled?: boolean; isAdminPageEnabled?: boolean; + isPageLayoutFeatureFlagEnabled?: boolean; }; export const SettingsRoutes = ({ isFunctionSettingsEnabled, isAdminPageEnabled, + isPageLayoutFeatureFlagEnabled, }: SettingsRoutesProps) => ( }> @@ -604,10 +614,22 @@ export const SettingsRoutes = ({ /> )} - } - /> + {isPageLayoutFeatureFlagEnabled && ( + <> + } + /> + } + /> + } + /> + + )} createBrowserRouter( createRoutesFromElements( @@ -69,6 +70,7 @@ export const useCreateAppRouter = ( } /> diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx index 189b90b71e..71b5573cf7 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx @@ -6,9 +6,13 @@ 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 { SettingsPath } from '@/types/SettingsPath'; 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 { isDefined } from 'twenty-shared/utils'; @@ -19,8 +23,9 @@ 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) @@ -30,6 +35,13 @@ 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 ( @@ -52,6 +64,7 @@ export const CommandMenuRouter = () => { isInRightDrawer={true} displayType="listItem" actionMenuType="command-menu" + objectMetadataItemOverride={objectMetadataItemOverride} > {commandMenuPageComponent} diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 833a7cbeed..54cbfae8b4 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -3,6 +3,8 @@ import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage'; import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; +import { CommandMenuPageLayoutGraphTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect'; +import { CommandMenuPageLayoutWidgetTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect'; import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage'; import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage'; @@ -38,4 +40,12 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< [CommandMenuPages.SearchRecords, ], [CommandMenuPages.AskAI, ], [CommandMenuPages.ViewPreviousAIChats, ], + [ + CommandMenuPages.PageLayoutWidgetTypeSelect, + , + ], + [ + CommandMenuPages.PageLayoutGraphTypeSelect, + , + ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx new file mode 100644 index 0000000000..ac9d099d61 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx @@ -0,0 +1,77 @@ +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { usePageLayoutWidgetCreate } from '@/settings/page-layout/hooks/usePageLayoutWidgetCreate'; +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import styled from '@emotion/styled'; +import { + IconChartBar, + IconChartPie, + IconGauge, + IconNumber, +} from 'twenty-ui/display'; +import { MenuItemCommand } from 'twenty-ui/navigation'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSectionTitle = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-top: ${({ theme }) => theme.spacing(2)}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + +const graphTypeOptions = [ + { + type: GraphSubType.BAR, + icon: IconChartBar, + title: 'Bar Chart', + }, + { + type: GraphSubType.PIE, + icon: IconChartPie, + title: 'Pie Chart', + }, + { + type: GraphSubType.GAUGE, + icon: IconGauge, + title: 'Gauge', + }, + { + type: GraphSubType.NUMBER, + icon: IconNumber, + title: 'Number', + }, +]; + +export const CommandMenuPageLayoutGraphTypeSelect = () => { + const { closeCommandMenu } = useCommandMenu(); + const { handleCreateWidget } = usePageLayoutWidgetCreate(); + + const handleSelectGraphType = (graphType: GraphSubType) => { + handleCreateWidget(WidgetType.GRAPH, graphType); + closeCommandMenu(); + }; + + return ( + + Graph type + + {graphTypeOptions.map((option) => ( + handleSelectGraphType(option.type)} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx new file mode 100644 index 0000000000..da7a3c12be --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx @@ -0,0 +1,93 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { WidgetType } from '@/settings/page-layout/mocks/mockWidgets'; +import { pageLayoutDraggedAreaState } from '@/settings/page-layout/states/pageLayoutDraggedAreaState'; +import styled from '@emotion/styled'; +import { useSetRecoilState } from 'recoil'; +import { IconChartPie, IconFrame, IconList } from 'twenty-ui/display'; +import { MenuItemCommand } from 'twenty-ui/navigation'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSectionTitle = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-top: ${({ theme }) => theme.spacing(2)}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledDisabledMenuItem = styled.div` + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +`; + +const widgetTypeOptions = [ + { + type: WidgetType.GRAPH, + icon: IconChartPie, + title: 'Add a graph', + disabled: false, + }, + { + type: WidgetType.VIEW, + icon: IconList, + title: 'Add a view', + disabled: true, + }, + { + type: WidgetType.IFRAME, + icon: IconFrame, + title: 'Add an iframe', + disabled: true, + }, +]; + +export const CommandMenuPageLayoutWidgetTypeSelect = () => { + const { navigateCommandMenu } = useNavigateCommandMenu(); + const setPageLayoutDraggedArea = useSetRecoilState( + pageLayoutDraggedAreaState, + ); + + const handleSelectWidget = (widgetType: WidgetType) => { + if (widgetType === WidgetType.GRAPH) { + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutGraphTypeSelect, + pageTitle: 'Select Graph Type', + pageIcon: IconChartPie, + }); + } else { + setPageLayoutDraggedArea(null); + } + }; + + return ( + + Widget type + {widgetTypeOptions.map((option) => { + const MenuItem = ( + handleSelectWidget(option.type)} + /> + ); + + return option.disabled ? ( + + {MenuItem} + + ) : ( + MenuItem + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 52c0459b8f..6643b6543d 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -14,4 +14,6 @@ export enum CommandMenuPages { SearchRecords = 'search-records', AskAI = 'ask-ai', ViewPreviousAIChats = 'view-previous-ai-chats', + PageLayoutWidgetTypeSelect = 'page-layout-widget-type-select', + PageLayoutGraphTypeSelect = 'page-layout-graph-type-select', } diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 164c995322..1738d1c6c1 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -9,6 +9,7 @@ export enum CoreObjectNameSingular { Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', + Dashboard = 'dashboard', TimelineActivity = 'timelineActivity', Favorite = 'favorite', FavoriteFolder = 'favoriteFolder', diff --git a/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx index b1e2a77f2e..23069dcad7 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx @@ -10,15 +10,19 @@ import { type ReactNode } from 'react'; type SettingsPageFullWidthContainerProps = { children: ReactNode; links: BreadcrumbProps['links']; + actionButton?: ReactNode; }; export const SettingsPageFullWidthContainer = ({ children, links, + actionButton, }: SettingsPageFullWidthContainerProps) => { return ( - } /> + }> + {actionButton} + {children} ); diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index 90a6fa5c9f..abd89f7436 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -145,8 +145,8 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { !isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE], }, { - label: t`Layout`, - path: SettingsPath.PageLayoutEdition, + label: t`Page Layouts`, + path: SettingsPath.PageLayout, Icon: IconLayout, isHidden: !isPageLayoutEnabled || diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx new file mode 100644 index 0000000000..e0ed4a173c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx @@ -0,0 +1,98 @@ +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 { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { type Widget } from '../mocks/mockWidgets'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { + PageLayoutType, + savedPageLayoutsState, + type SavedPageLayout, +} from '../states/savedPageLayoutsState'; + +type PageLayoutInitializationEffectProps = { + layoutId: string | undefined; + isEditMode: boolean; +}; + +export const PageLayoutInitializationEffect = ({ + layoutId, + isEditMode, +}: PageLayoutInitializationEffectProps) => { + const [isInitialized, setIsInitialized] = useState(false); + const savedPageLayouts = useRecoilValue(savedPageLayoutsState); + + const initializePageLayout = useRecoilCallback( + ({ set, snapshot }) => + (layout: SavedPageLayout | undefined) => { + const currentPersisted = getSnapshotValue( + snapshot, + pageLayoutPersistedState, + ); + + if (isDefined(layout)) { + if (!isDeeplyEqual(layout, currentPersisted)) { + set(pageLayoutPersistedState, layout); + set(pageLayoutDraftState, { + name: layout.name, + type: layout.type, + widgets: layout.widgets, + }); + + const widgets: Widget[] = layout.widgets.map((w) => ({ + id: w.id, + title: w.title, + type: w.type, + configuration: w.configuration, + data: w.data, + })); + set(pageLayoutWidgetsState, widgets); + + const layouts = layout.widgets.map((w) => ({ + i: w.id, + x: w.gridPosition.column, + y: w.gridPosition.row, + w: w.gridPosition.columnSpan, + h: w.gridPosition.rowSpan, + })); + set(pageLayoutCurrentLayoutsState, { + desktop: layouts, + mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })), + }); + } + } else { + set(pageLayoutDraftState, { + name: '', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + set(pageLayoutPersistedState, undefined); + set(pageLayoutWidgetsState, []); + set(pageLayoutCurrentLayoutsState, { desktop: [], mobile: [] }); + } + }, + [], + ); + + useEffect(() => { + if (!isInitialized) { + const existingLayout = isEditMode + ? savedPageLayouts.find((l) => l.id === layoutId) + : undefined; + initializePageLayout(existingLayout); + setIsInitialized(true); + } + }, [ + layoutId, + savedPageLayouts, + initializePageLayout, + isInitialized, + isEditMode, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx new file mode 100644 index 0000000000..3fd145d124 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx @@ -0,0 +1,131 @@ +import styled from '@emotion/styled'; +import { type ReactNode } from 'react'; +import { IconGripVertical, IconX } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, +} from 'twenty-ui/layout'; + +const StyledPlaceholderContainer = styled.div<{ isEmpty?: boolean }>` + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + width: 100%; + padding: ${({ theme }) => theme.spacing(4)}; + + &:hover { + cursor: ${({ isEmpty }) => (isEmpty ? 'pointer' : 'default')}; + border: 1px solid ${({ theme }) => theme.color.blue}; + background: ${({ theme }) => theme.background.primary}; + } +`; + +const StyledHeader = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledDragHandleButton = styled(IconButton)` + cursor: grab; + display: flex; + align-items: center; + user-select: none; + + &:active { + cursor: grabbing; + } +`; + +const StyledTitle = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + user-select: none; +`; + +const StyledContent = styled.div` + align-items: center; + display: flex; + height: 100%; + width: 100%; + justify-content: center; +`; + +type PageLayoutWidgetPlaceholderProps = { + title?: string; + onRemove?: () => void; + children?: ReactNode; + isEmpty?: boolean; +}; + +export const PageLayoutWidgetPlaceholder = ({ + title = 'Graph Title', + onRemove, + children, + isEmpty = false, +}: PageLayoutWidgetPlaceholderProps) => { + if (isEmpty) { + return ( + + + + Add Widget + + + + + + No widgets yet + + + Click to add your first widget + + + + + ); + } + + return ( + + + + {title} + {onRemove && ( + + )} + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx new file mode 100644 index 0000000000..d3219d7eb3 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx @@ -0,0 +1,313 @@ +import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; +import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; +import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; +import { type Meta, type StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { PageLayoutWidgetPlaceholder } from '../PageLayoutWidgetPlaceholder'; + +const meta: Meta = { + title: 'Modules/Settings/PageLayout/PageLayoutWidgetPlaceholder', + component: PageLayoutWidgetPlaceholder, + decorators: [ComponentDecorator], + parameters: { + layout: 'centered', + }, + argTypes: { + title: { + control: 'text', + description: 'Widget title', + }, + isEmpty: { + control: 'boolean', + description: 'Show empty state', + }, + onRemove: { + action: 'onRemove', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithNumberChart: Story = { + args: { + title: 'Sales Pipeline', + children: , + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithGaugeChart: Story = { + args: { + title: 'Conversion Rate', + children: ( + + ), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const EmptyState: Story = { + args: { + isEmpty: true, + }, + parameters: { + docs: { + description: { + story: 'Empty widget placeholder state with dashed border', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithPieChart: Story = { + args: { + title: 'Lead Distribution', + children: ( + + ), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const SmallWidget: Story = { + args: { + title: 'Small Widget (2x2 grid)', + children: , + }, + parameters: { + docs: { + description: { + story: 'Simulates a 2x2 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const MediumWidget: Story = { + args: { + title: 'Medium Widget (4x3 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a 4x3 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const LargeWidget: Story = { + args: { + title: 'Large Widget (6x4 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a 6x4 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WideWidget: Story = { + args: { + title: 'Wide Widget (8x2 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a wide 8x2 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const TallWidget: Story = { + args: { + title: 'Tall Widget (3x6 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a tall 3x6 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts new file mode 100644 index 0000000000..4116515f53 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts @@ -0,0 +1,6 @@ +import { type Layouts } from 'react-grid-layout'; + +export const EMPTY_LAYOUT: Layouts = { + desktop: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], + mobile: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }], +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts new file mode 100644 index 0000000000..a25e1ddf73 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts @@ -0,0 +1 @@ +export const GRID_BUFFER_ROWS = 10; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts new file mode 100644 index 0000000000..c0a04f1a42 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts @@ -0,0 +1 @@ +export const GRID_MIN_ROWS = 25; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts new file mode 100644 index 0000000000..115d947c86 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts @@ -0,0 +1,14 @@ +import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; + +export const PAGE_LAYOUT_CONFIG = { + breakpoints: { + desktop: MOBILE_VIEWPORT, + mobile: 0, + }, + columns: { + desktop: 12, + mobile: 1, + }, +} as const; + +export type PageLayoutBreakpoint = 'desktop' | 'mobile'; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts new file mode 100644 index 0000000000..0cdf2116ae --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts @@ -0,0 +1,80 @@ +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutDraftState } from '../usePageLayoutDraftState'; + +describe('usePageLayoutDraftState', () => { + it('should detect dirty state when draft differs from persisted', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + 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, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: ' ', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.canSave).toBe(false); + }); + + it('should allow updating draft state', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: 'Updated Name', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + }); + + expect(result.current.pageLayoutDraft.name).toBe('Updated Name'); + expect(result.current.canSave).toBe(true); + expect(result.current.isDirty).toBe(true); + }); + + it('should detect changes in widgets', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: 'Test Layout', + type: PageLayoutType.DASHBOARD, + widgets: [ + { + id: 'widget-1', + title: 'New Widget', + type: WidgetType.GRAPH, + gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 }, + configuration: { graphType: GraphSubType.BAR }, + data: {}, + }, + ], + }); + }); + + expect(result.current.isDirty).toBe(true); + expect(result.current.canSave).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts new file mode 100644 index 0000000000..31de4c8084 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts @@ -0,0 +1,100 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutDragSelection } from '../usePageLayoutDragSelection'; + +describe('usePageLayoutDragSelection', () => { + it('should initialize with empty selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should clear selected cells on drag start', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('cell-1', true); + result.current.handleDragSelectionChange('cell-2', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(2); + + act(() => { + result.current.handleDragSelectionStart(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should add and remove cells during drag selection', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('cell-1', true); + }); + + expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(true); + + act(() => { + result.current.handleDragSelectionChange('cell-2', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(2); + + act(() => { + result.current.handleDragSelectionChange('cell-1', false); + }); + + expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(false); + expect(result.current.pageLayoutSelectedCells.size).toBe(1); + }); + + it('should handle drag selection end with selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('0-0', true); + result.current.handleDragSelectionChange('1-0', true); + result.current.handleDragSelectionChange('0-1', true); + result.current.handleDragSelectionChange('1-1', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(4); + + act(() => { + result.current.handleDragSelectionEnd(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should handle drag selection end with no selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionEnd(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should provide all required handler functions', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + expect(typeof result.current.handleDragSelectionStart).toBe('function'); + expect(typeof result.current.handleDragSelectionChange).toBe('function'); + expect(typeof result.current.handleDragSelectionEnd).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts new file mode 100644 index 0000000000..c98a1d6c90 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange'; + +describe('usePageLayoutHandleLayoutChange', () => { + it('should update layouts and draft state when layout changes', () => { + const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), { + 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.handleLayoutChange([], newLayouts); + }); + + expect(typeof result.current.handleLayoutChange).toBe('function'); + }); + + it('should handle empty layouts', () => { + const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), { + wrapper: RecoilRoot, + }); + + const emptyLayouts = { + desktop: [], + mobile: [], + }; + + act(() => { + result.current.handleLayoutChange([], emptyLayouts); + }); + + expect(typeof result.current.handleLayoutChange).toBe('function'); + }); + + it('should maintain callback reference across renders', () => { + const { result, rerender } = renderHook( + () => usePageLayoutHandleLayoutChange(), + { + wrapper: RecoilRoot, + }, + ); + + const firstCallback = result.current.handleLayoutChange; + + rerender(); + + const secondCallback = result.current.handleLayoutChange; + + expect(firstCallback).toBe(secondCallback); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts new file mode 100644 index 0000000000..47220e3a9f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts @@ -0,0 +1,50 @@ +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutWidgetCreate } from '../usePageLayoutWidgetCreate'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid'), +})); + +describe('usePageLayoutWidgetCreate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create a new widget with default position', () => { + const { result } = renderHook(() => usePageLayoutWidgetCreate(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleCreateWidget(WidgetType.GRAPH, GraphSubType.BAR); + }); + + expect(typeof result.current.handleCreateWidget).toBe('function'); + }); + + it('should handle different graph types', () => { + const { result } = renderHook(() => usePageLayoutWidgetCreate(), { + wrapper: RecoilRoot, + }); + + const graphTypes = [ + GraphSubType.NUMBER, + GraphSubType.GAUGE, + GraphSubType.PIE, + GraphSubType.BAR, + ]; + + graphTypes.forEach((graphType) => { + act(() => { + result.current.handleCreateWidget(WidgetType.GRAPH, graphType); + }); + }); + + expect(typeof result.current.handleCreateWidget).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts new file mode 100644 index 0000000000..53bcfc4d6d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutWidgetDelete } from '../usePageLayoutWidgetDelete'; + +describe('usePageLayoutWidgetDelete', () => { + it('should remove widget from all states', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('widget-1'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); + + it('should handle removing non-existent widget', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('non-existent-widget'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); + + it('should handle empty layouts', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('any-widget'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts new file mode 100644 index 0000000000..d589e209fd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts @@ -0,0 +1,28 @@ +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; + +export const usePageLayoutDraftState = () => { + const [pageLayoutDraft, setPageLayoutDraft] = + useRecoilState(pageLayoutDraftState); + const pageLayoutPersisted = useRecoilValue(pageLayoutPersistedState); + + const isDirty = pageLayoutPersisted + ? !isDeeplyEqual(pageLayoutDraft, { + name: pageLayoutPersisted.name, + type: pageLayoutPersisted.type, + widgets: pageLayoutPersisted.widgets, + }) + : pageLayoutDraft.name.trim().length > 0 || + pageLayoutDraft.widgets.length > 0; + + const canSave = pageLayoutDraft.name?.trim().length > 0; + + return { + pageLayoutDraft, + setPageLayoutDraft, + isDirty, + canSave, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts new file mode 100644 index 0000000000..25706d0a0e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts @@ -0,0 +1,61 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { IconAppWindow } from 'twenty-ui/display'; +import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState'; +import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState'; +import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells'; + +export const usePageLayoutDragSelection = () => { + const [pageLayoutSelectedCells, setPageLayoutSelectedCells] = useRecoilState( + pageLayoutSelectedCellsState, + ); + const setPageLayoutDraggedArea = useSetRecoilState( + pageLayoutDraggedAreaState, + ); + + const { navigateCommandMenu } = useNavigateCommandMenu(); + const handleDragSelectionStart = () => { + setPageLayoutSelectedCells(new Set()); + }; + + const handleDragSelectionChange = (cellId: string, selected: boolean) => { + setPageLayoutSelectedCells((prev) => { + const newSet = new Set(prev); + if (selected) { + newSet.add(cellId); + } else { + newSet.delete(cellId); + } + return newSet; + }); + }; + + const handleDragSelectionEnd = () => { + if (pageLayoutSelectedCells.size > 0) { + const draggedBounds = calculateGridBoundsFromSelectedCells( + Array.from(pageLayoutSelectedCells), + ); + + if (draggedBounds !== null) { + setPageLayoutDraggedArea(draggedBounds); + + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutWidgetTypeSelect, + pageTitle: 'Add Widget', + pageIcon: IconAppWindow, + resetNavigationStack: true, + }); + + setPageLayoutSelectedCells(new Set()); + } + } + }; + + return { + pageLayoutSelectedCells, + handleDragSelectionStart, + handleDragSelectionChange, + handleDragSelectionEnd, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts new file mode 100644 index 0000000000..1cb791fe6b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts @@ -0,0 +1,32 @@ +import { type Layout, type Layouts } from 'react-grid-layout'; +import { useRecoilCallback } from 'recoil'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets'; + +export const usePageLayoutHandleLayoutChange = () => { + const handleLayoutChange = useRecoilCallback( + ({ snapshot, set }) => + (_: Layout[], allLayouts: Layouts) => { + set(pageLayoutCurrentLayoutsState, allLayouts); + + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + + const updatedWidgets = convertLayoutsToWidgets( + pageLayoutWidgets, + allLayouts, + ); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: updatedWidgets, + })); + }, + [], + ); + + return { handleLayoutChange }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts new file mode 100644 index 0000000000..b0eb0ac9d7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts @@ -0,0 +1,63 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; +import { + savedPageLayoutsState, + type SavedPageLayout, +} from '../states/savedPageLayoutsState'; + +type WidgetWithGridPosition = SavedPageLayout['widgets'][0]; + +export const usePageLayoutSaveHandler = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const isEditMode = id && id !== 'new'; + + const savePageLayout = useRecoilCallback( + ({ snapshot, set }) => + async (widgetsWithPositions?: WidgetWithGridPosition[]) => { + const pageLayoutDraft = snapshot + .getLoadable(pageLayoutDraftState) + .getValue(); + const savedPageLayouts = snapshot + .getLoadable(savedPageLayoutsState) + .getValue(); + + const existingLayout = isEditMode + ? savedPageLayouts.find((layout) => layout.id === id) + : undefined; + + const widgets = widgetsWithPositions || pageLayoutDraft.widgets; + + const layoutToSave: SavedPageLayout = { + id: isEditMode ? id : uuidv4(), + name: pageLayoutDraft.name, + type: pageLayoutDraft.type, + createdAt: isEditMode + ? existingLayout?.createdAt || new Date().toISOString() + : new Date().toISOString(), + updatedAt: new Date().toISOString(), + widgets, + }; + + set(savedPageLayoutsState, (prev) => { + if (isDefined(isEditMode)) { + return prev.map((layout) => + layout.id === id ? layoutToSave : layout, + ); + } + return [...prev, layoutToSave]; + }); + + set(pageLayoutPersistedState, layoutToSave); + + navigate('/settings/page-layout'); + }, + [isEditMode, id, navigate], + ); + + return { savePageLayout }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts new file mode 100644 index 0000000000..1f5244b93a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts @@ -0,0 +1,98 @@ +import { useRecoilCallback } from 'recoil'; +import { v4 as uuidv4 } from 'uuid'; +import { + type GraphSubType, + type Widget, + type WidgetType, +} from '../mocks/mockWidgets'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { + getDefaultWidgetData, + getWidgetSize, + getWidgetTitle, +} from '../utils/getDefaultWidgetData'; +import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition'; + +export const usePageLayoutWidgetCreate = () => { + const handleCreateWidget = useRecoilCallback( + ({ snapshot, set }) => + (widgetType: WidgetType, graphType: GraphSubType) => { + const widgetData = getDefaultWidgetData(graphType); + + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + const pageLayoutCurrentLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + const pageLayoutDraggedArea = snapshot + .getLoadable(pageLayoutDraggedAreaState) + .getValue(); + + const existingWidgetCount = pageLayoutWidgets.filter( + (w) => + w.type === widgetType && w.configuration?.graphType === graphType, + ).length; + const title = getWidgetTitle(graphType, existingWidgetCount); + + const newWidget: Widget = { + id: `widget-${uuidv4()}`, + type: widgetType, + title, + configuration: { + graphType, + }, + data: widgetData, + }; + + const defaultSize = getWidgetSize(graphType); + const position = getDefaultWidgetPosition( + pageLayoutDraggedArea, + defaultSize, + ); + + const newLayout = { + i: newWidget.id, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + }; + + const updatedWidgets = [...pageLayoutWidgets, newWidget]; + set(pageLayoutWidgetsState, updatedWidgets); + + const updatedLayouts = { + desktop: [...(pageLayoutCurrentLayouts.desktop || []), newLayout], + mobile: [ + ...(pageLayoutCurrentLayouts.mobile || []), + { ...newLayout, w: 1, x: 0 }, + ], + }; + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + const widgetWithPosition = { + ...newWidget, + gridPosition: { + row: position.y, + column: position.x, + rowSpan: position.h, + columnSpan: position.w, + }, + }; + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: [...prev.widgets, widgetWithPosition], + })); + + set(pageLayoutDraggedAreaState, null); + }, + [], + ); + + return { handleCreateWidget }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts new file mode 100644 index 0000000000..3e3bb4143b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts @@ -0,0 +1,41 @@ +import { useRecoilCallback } from 'recoil'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; + +export const usePageLayoutWidgetDelete = () => { + const handleRemoveWidget = useRecoilCallback( + ({ snapshot, set }) => + (widgetId: string) => { + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + const pageLayoutCurrentLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + + const updatedWidgets = pageLayoutWidgets.filter( + (w) => w.id !== widgetId, + ); + set(pageLayoutWidgetsState, updatedWidgets); + + const updatedLayouts = { + desktop: (pageLayoutCurrentLayouts.desktop || []).filter( + (layout) => layout.i !== widgetId, + ), + mobile: (pageLayoutCurrentLayouts.mobile || []).filter( + (layout) => layout.i !== widgetId, + ), + }; + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: prev.widgets.filter((w) => w.id !== widgetId), + })); + }, + [], + ); + + return { handleRemoveWidget }; +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts similarity index 51% rename from packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts rename to packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts index 17059ebbb7..9e61103d8d 100644 --- a/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts @@ -1,19 +1,35 @@ import { type Layouts } from 'react-grid-layout'; -export type WidgetType = 'number' | 'gauge' | 'pie' | 'view' | 'iframe'; +export enum WidgetType { + VIEW = 'VIEW', + IFRAME = 'IFRAME', + FIELDS = 'FIELDS', + GRAPH = 'GRAPH', +} + +export enum GraphSubType { + NUMBER = 'NUMBER', + GAUGE = 'GAUGE', + PIE = 'PIE', + BAR = 'BAR', +} export type Widget = { id: string; type: WidgetType; title: string; + configuration?: Record; data?: any; }; export const mockWidgets: Widget[] = [ { id: 'widget-1', - type: 'number', + type: WidgetType.GRAPH, title: 'Sales Pipeline', + configuration: { + graphType: GraphSubType.NUMBER, + }, data: { value: '1,234', trendPercentage: 12.5, @@ -21,8 +37,11 @@ export const mockWidgets: Widget[] = [ }, { id: 'widget-2', - type: 'gauge', + type: WidgetType.GRAPH, title: 'Conversion Rate', + configuration: { + graphType: GraphSubType.GAUGE, + }, data: { value: 0.5, min: 0, @@ -32,8 +51,11 @@ export const mockWidgets: Widget[] = [ }, { id: 'widget-3', - type: 'pie', + type: WidgetType.GRAPH, title: 'Lead Distribution', + configuration: { + graphType: GraphSubType.PIE, + }, data: { items: [ { @@ -69,10 +91,64 @@ export const mockWidgets: Widget[] = [ ], }, }, + { + id: 'widget-4', + type: WidgetType.GRAPH, + title: 'Monthly Performance', + configuration: { + graphType: GraphSubType.BAR, + }, + data: { + items: [ + { + month: 'Jan', + sales: 120, + leads: 45, + conversions: 12, + to: '/metrics/january', + }, + { + month: 'Feb', + sales: 150, + leads: 52, + conversions: 15, + to: '/metrics/february', + }, + { + month: 'Mar', + sales: 180, + leads: 48, + conversions: 18, + to: '/metrics/march', + }, + { + month: 'Apr', + sales: 140, + leads: 60, + conversions: 14, + to: '/metrics/april', + }, + { + month: 'May', + sales: 200, + leads: 55, + conversions: 20, + to: '/metrics/may', + }, + ], + indexBy: 'month', + keys: ['sales', 'leads', 'conversions'], + showLegend: true, + showGrid: true, + displayType: 'number', + xAxisLabel: 'Month', + yAxisLabel: 'Count', + }, + }, ]; export const mockLayouts: Layouts = { - lg: [ + desktop: [ { i: 'widget-1', x: 0, @@ -94,31 +170,15 @@ export const mockLayouts: Layouts = { w: 6, h: 5, }, - ], - md: [ { - i: 'widget-1', - x: 0, + i: 'widget-4', + x: 9, y: 0, - w: 3, - h: 2, - }, - { - i: 'widget-2', - x: 3, - y: 0, - w: 3, - h: 5, - }, - { - i: 'widget-3', - x: 6, - y: 0, - w: 6, - h: 5, + w: 4, + h: 8, }, ], - sm: [ + mobile: [ { i: 'widget-1', x: 0, @@ -140,5 +200,12 @@ export const mockLayouts: Layouts = { w: 1, h: 5, }, + { + i: 'widget-4', + x: 0, + y: 12, + w: 1, + h: 5, + }, ], }; diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts new file mode 100644 index 0000000000..665d60995a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui/utilities'; +import { type PageLayoutBreakpoint } from '../constants/PageLayoutBreakpoints'; + +export const pageLayoutCurrentBreakpointState = + createState({ + key: 'pageLayoutCurrentBreakpointState', + defaultValue: 'desktop', + }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts new file mode 100644 index 0000000000..1edc017f62 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts @@ -0,0 +1,7 @@ +import { type Layouts } from 'react-grid-layout'; +import { createState } from 'twenty-ui/utilities'; + +export const pageLayoutCurrentLayoutsState = createState({ + key: 'pageLayoutCurrentLayoutsState', + defaultValue: { desktop: [], mobile: [] }, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts new file mode 100644 index 0000000000..bb69b28ae6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts @@ -0,0 +1,16 @@ +import { createState } from 'twenty-ui/utilities'; +import { PageLayoutType, type SavedPageLayout } from './savedPageLayoutsState'; + +export type DraftPageLayout = Omit< + SavedPageLayout, + 'id' | 'createdAt' | 'updatedAt' +>; + +export const pageLayoutDraftState = createState({ + key: 'pageLayoutDraftState', + defaultValue: { + name: '', + type: PageLayoutType.DASHBOARD, + widgets: [], + }, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts new file mode 100644 index 0000000000..d0c1a37b66 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts @@ -0,0 +1,13 @@ +import { createState } from 'twenty-ui/utilities'; + +type DraggedArea = { + x: number; + y: number; + w: number; + h: number; +} | null; + +export const pageLayoutDraggedAreaState = createState({ + key: 'pageLayoutDraggedAreaState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts new file mode 100644 index 0000000000..aa1dc4a6fc --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui/utilities'; +import { type SavedPageLayout } from './savedPageLayoutsState'; + +export const pageLayoutPersistedState = createState< + SavedPageLayout | undefined +>({ + key: 'pageLayoutPersistedState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts new file mode 100644 index 0000000000..26f4200c0b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const pageLayoutSelectedCellsState = createState>({ + key: 'pageLayoutSelectedCellsState', + defaultValue: new Set(), +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts new file mode 100644 index 0000000000..ae751042e8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts @@ -0,0 +1,7 @@ +import { createState } from 'twenty-ui/utilities'; +import { type Widget } from '../mocks/mockWidgets'; + +export const pageLayoutWidgetsState = createState({ + key: 'pageLayoutWidgetsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts new file mode 100644 index 0000000000..2656d813ae --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts @@ -0,0 +1,34 @@ +import { type WidgetType } from '@/settings/page-layout/mocks/mockWidgets'; +import { createState } from 'twenty-ui/utilities'; + +export enum PageLayoutType { + DASHBOARD = 'DASHBOARD', + RECORD_INDEX = 'RECORD_INDEX', + RECORD_PAGE = 'RECORD_PAGE', +} + +export type SavedPageLayout = { + id: string; + name: string; + type: PageLayoutType; + createdAt: string; + updatedAt: string; + widgets: Array<{ + id: string; + title: string; + type: WidgetType; + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }; + configuration?: Record; + data?: any; // TODO: Remove when backend connected - data will be fetched dynamically + }>; +}; + +export const savedPageLayoutsState = createState({ + key: 'savedPageLayoutsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts new file mode 100644 index 0000000000..9bf87c94e5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts @@ -0,0 +1,138 @@ +import { calculateGridBoundsFromSelectedCells } from '../calculateGridBoundsFromSelectedCells'; + +describe('calculateGridBoundsFromSelectedCells', () => { + it('should return null for empty array', () => { + expect(calculateGridBoundsFromSelectedCells([])).toBeNull(); + }); + + it('should calculate bounds for single cell', () => { + expect(calculateGridBoundsFromSelectedCells(['cell-2-3'])).toEqual({ + x: 2, + y: 3, + w: 1, + h: 1, + }); + }); + + it('should calculate bounds for rectangular selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-1-1', + 'cell-2-1', + 'cell-1-2', + 'cell-2-2', + ]), + ).toEqual({ + x: 1, + y: 1, + w: 2, + h: 2, + }); + }); + + it('should handle non-contiguous selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-5-3', + 'cell-2-1', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 6, + h: 4, + }); + }); + + it('should handle large grid selections', () => { + expect( + calculateGridBoundsFromSelectedCells(['cell-0-0', 'cell-11-24']), + ).toEqual({ + x: 0, + y: 0, + w: 12, + h: 25, + }); + }); + + it('should handle single row selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-5', + 'cell-1-5', + 'cell-2-5', + 'cell-3-5', + ]), + ).toEqual({ + x: 0, + y: 5, + w: 4, + h: 1, + }); + }); + + it('should handle single column selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-3-0', + 'cell-3-1', + 'cell-3-2', + 'cell-3-3', + ]), + ).toEqual({ + x: 3, + y: 0, + w: 1, + h: 4, + }); + }); + + it('should handle duplicate cell IDs in selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-1-1', + 'cell-1-1', + 'cell-2-2', + 'cell-2-2', + ]), + ).toEqual({ + x: 1, + y: 1, + w: 2, + h: 2, + }); + }); + + it('should handle L-shaped selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-1-0', + 'cell-0-1', + 'cell-0-2', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 2, + h: 3, + }); + }); + + it('should handle sparse diagonal selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-1-1', + 'cell-2-2', + 'cell-3-3', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 4, + h: 4, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts new file mode 100644 index 0000000000..83c5f6ac6d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts @@ -0,0 +1,40 @@ +import { GRID_MIN_ROWS } from '../../constants/GridMinRows'; +import { calculateTotalGridRows } from '../calculateTotalGridRows'; + +describe('calculateTotalGridRows', () => { + it('should return minimum rows for empty layouts', () => { + expect(calculateTotalGridRows({})).toBe(GRID_MIN_ROWS); + }); + + it('should calculate rows based on content when exceeding minimum', () => { + const layouts = { + desktop: [ + { i: '1', x: 0, y: 0, w: 2, h: 2 }, + { i: '2', x: 2, y: 20, w: 2, h: 3 }, + ], + }; + expect(calculateTotalGridRows(layouts)).toBe(33); + }); + + it('should respect minimum rows even with content', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 0, w: 1, h: 1 }], + }; + expect(calculateTotalGridRows(layouts)).toBe(GRID_MIN_ROWS); + }); + + it('should handle custom min and buffer values', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 10, w: 1, h: 5 }], + }; + expect(calculateTotalGridRows(layouts, 10, 5)).toBe(20); + }); + + it('should consider both desktop and mobile layouts', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 5, w: 2, h: 2 }], + mobile: [{ i: '1', x: 0, y: 25, w: 1, h: 3 }], + }; + expect(calculateTotalGridRows(layouts)).toBe(38); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts new file mode 100644 index 0000000000..964498b3e0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts @@ -0,0 +1,79 @@ +import { GraphSubType, WidgetType, type Widget } from '../../mocks/mockWidgets'; +import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets'; + +describe('convertLayoutsToWidgets', () => { + const mockWidgets: Widget[] = [ + { + id: 'widget-1', + title: 'Widget 1', + type: WidgetType.GRAPH, + configuration: { + graphType: GraphSubType.NUMBER, + }, + data: { value: 100 }, + }, + { + id: 'widget-2', + title: 'Widget 2', + type: WidgetType.GRAPH, + configuration: { + graphType: GraphSubType.PIE, + }, + data: { items: [] }, + }, + ]; + + it('should map layout positions to widgets', () => { + const layouts = { + desktop: [ + { i: 'widget-1', x: 2, y: 3, w: 4, h: 5 }, + { i: 'widget-2', x: 6, y: 7, w: 8, h: 9 }, + ], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[0].gridPosition).toEqual({ + column: 2, + row: 3, + columnSpan: 4, + rowSpan: 5, + }); + expect(result[1].gridPosition).toEqual({ + column: 6, + row: 7, + columnSpan: 8, + rowSpan: 9, + }); + }); + + it('should use defaults when layout not found', () => { + const layouts = { + desktop: [{ i: 'widget-1', x: 1, y: 1, w: 1, h: 1 }], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[1].gridPosition).toEqual({ + column: 0, + row: 0, + columnSpan: 2, + rowSpan: 2, + }); + }); + + it('should handle mobile layout', () => { + const layouts = { + mobile: [{ i: 'widget-1', x: 0, y: 4, w: 1, h: 6 }], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[0].gridPosition).toEqual({ + column: 0, + row: 4, + columnSpan: 1, + rowSpan: 6, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts new file mode 100644 index 0000000000..71b5abb831 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts @@ -0,0 +1,15 @@ +import { generateCellId } from '../generateCellId'; + +describe('generateCellId', () => { + it('should generate cell ID with correct format', () => { + expect(generateCellId(3, 5)).toBe('cell-3-5'); + }); + + it('should handle zero values', () => { + expect(generateCellId(0, 0)).toBe('cell-0-0'); + }); + + it('should handle large numbers', () => { + expect(generateCellId(100, 200)).toBe('cell-100-200'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts new file mode 100644 index 0000000000..e74301769d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts @@ -0,0 +1,23 @@ +import { getDefaultWidgetPosition } from '../getDefaultWidgetPosition'; + +describe('getDefaultWidgetPosition', () => { + it('should return dragged area when provided', () => { + const draggedArea = { x: 2, y: 3, w: 4, h: 5 }; + const defaultSize = { w: 2, h: 2 }; + + expect(getDefaultWidgetPosition(draggedArea, defaultSize)).toEqual( + draggedArea, + ); + }); + + it('should return default position with size when no dragged area', () => { + const defaultSize = { w: 3, h: 4 }; + + expect(getDefaultWidgetPosition(null, defaultSize)).toEqual({ + x: 0, + y: 0, + w: 3, + h: 4, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts new file mode 100644 index 0000000000..2dddbc1d63 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts @@ -0,0 +1,18 @@ +import { parseCellIdToCoordinates } from '../parseCellIdToCoordinates'; + +describe('parseCellIdToCoordinates', () => { + it('should parse cell ID correctly', () => { + expect(parseCellIdToCoordinates('cell-3-5')).toEqual({ col: 3, row: 5 }); + }); + + it('should handle zero coordinates', () => { + expect(parseCellIdToCoordinates('cell-0-0')).toEqual({ col: 0, row: 0 }); + }); + + it('should handle double-digit coordinates', () => { + expect(parseCellIdToCoordinates('cell-12-25')).toEqual({ + col: 12, + row: 25, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts new file mode 100644 index 0000000000..e65112f8f4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts @@ -0,0 +1,30 @@ +import { parseCellIdToCoordinates } from './parseCellIdToCoordinates'; + +export type GridBounds = { + x: number; + y: number; + w: number; + h: number; +}; + +export const calculateGridBoundsFromSelectedCells = ( + selectedCellIds: string[], +): GridBounds | null => { + if (selectedCellIds.length === 0) { + return null; + } + + const cellCoords = selectedCellIds.map(parseCellIdToCoordinates); + + const minCol = Math.min(...cellCoords.map((c) => c.col)); + const maxCol = Math.max(...cellCoords.map((c) => c.col)); + const minRow = Math.min(...cellCoords.map((c) => c.row)); + const maxRow = Math.max(...cellCoords.map((c) => c.row)); + + return { + x: minCol, + y: minRow, + w: maxCol - minCol + 1, + h: maxRow - minRow + 1, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts new file mode 100644 index 0000000000..f382ccbabb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts @@ -0,0 +1,18 @@ +import { type Layouts } from 'react-grid-layout'; +import { GRID_BUFFER_ROWS } from '../constants/GridBufferRows'; +import { GRID_MIN_ROWS } from '../constants/GridMinRows'; + +export const calculateTotalGridRows = ( + layouts: Layouts, + minRows = GRID_MIN_ROWS, + bufferRows = GRID_BUFFER_ROWS, +): number => { + const allLayouts = [...(layouts.desktop || []), ...(layouts.mobile || [])]; + + const contentRows = + allLayouts.length === 0 + ? 0 + : Math.max(...allLayouts.map((item) => item.y + item.h)); + + return Math.max(minRows, contentRows + bufferRows); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts new file mode 100644 index 0000000000..e62ccc4056 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts @@ -0,0 +1,31 @@ +import { type Layouts } from 'react-grid-layout'; +import { type Widget } from '../mocks/mockWidgets'; + +export type WidgetWithGridPosition = Widget & { + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }; +}; + +export const convertLayoutsToWidgets = ( + widgets: Widget[], + layouts: Layouts, +): WidgetWithGridPosition[] => { + const activeLayouts = layouts.desktop || layouts.mobile || []; + + return widgets.map((widget) => { + const layout = activeLayouts.find((l) => l.i === widget.id); + return { + ...widget, + gridPosition: { + row: layout?.y ?? 0, + column: layout?.x ?? 0, + rowSpan: layout?.h ?? 2, + columnSpan: layout?.w ?? 2, + }, + }; + }); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts new file mode 100644 index 0000000000..e932de6edd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts @@ -0,0 +1,3 @@ +export const generateCellId = (col: number, row: number): string => { + return `cell-${col}-${row}`; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts new file mode 100644 index 0000000000..6370e87eed --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts @@ -0,0 +1,76 @@ +import { GraphSubType } from '../mocks/mockWidgets'; + +export const getDefaultWidgetData = (graphType: GraphSubType) => { + switch (graphType) { + case GraphSubType.NUMBER: + return { + value: '1,234', + trendPercentage: 15.2, + }; + + case GraphSubType.GAUGE: + return { + value: 0.7, + min: 0, + max: 1, + label: 'Progress', + }; + + case GraphSubType.PIE: + return { + items: [ + { id: 'segment1', value: 35, label: 'Segment A' }, + { id: 'segment2', value: 28, label: 'Segment B' }, + { id: 'segment3', value: 20, label: 'Segment C' }, + { id: 'segment4', value: 17, label: 'Segment D' }, + ], + }; + + case GraphSubType.BAR: + return { + items: [ + { category: 'Jan', value: 45 }, + { category: 'Feb', value: 52 }, + { category: 'Mar', value: 48 }, + { category: 'Apr', value: 61 }, + { category: 'May', value: 55 }, + ], + indexBy: 'category', + keys: ['value'], + seriesLabels: { value: 'Value' }, + layout: 'vertical' as const, + }; + + default: + return {}; + } +}; + +export const getWidgetTitle = ( + graphType: GraphSubType, + index: number, +): string => { + const baseNames: Record = { + [GraphSubType.NUMBER]: 'Number', + [GraphSubType.GAUGE]: 'Gauge', + [GraphSubType.PIE]: 'Pie Chart', + [GraphSubType.BAR]: 'Bar Chart', + }; + + return `${baseNames[graphType] || 'Widget'} ${index + 1}`; +}; + +export const getWidgetSize = (graphType: GraphSubType) => { + switch (graphType) { + case GraphSubType.NUMBER: + return { w: 3, h: 2 }; + case GraphSubType.GAUGE: + return { w: 3, h: 3 }; + case GraphSubType.PIE: + return { w: 4, h: 4 }; + case GraphSubType.BAR: + return { w: 6, h: 4 }; + default: + return { w: 4, h: 4 }; + } +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts new file mode 100644 index 0000000000..3fe5aff634 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts @@ -0,0 +1,17 @@ +import { type GridBounds } from './calculateGridBoundsFromSelectedCells'; + +export const getDefaultWidgetPosition = ( + draggedArea: GridBounds | null, + defaultSize: { w: number; h: number }, +): GridBounds => { + if (draggedArea !== null) { + return draggedArea; + } + + return { + x: 0, + y: 0, + w: defaultSize.w, + h: defaultSize.h, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts new file mode 100644 index 0000000000..4e67a7cbe7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts @@ -0,0 +1,9 @@ +export type CellCoordinate = { + col: number; + row: number; +}; + +export const parseCellIdToCoordinates = (cellId: string): CellCoordinate => { + const [col, row] = cellId.split('-').slice(1).map(Number); + return { col, row }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx new file mode 100644 index 0000000000..f6c9fc293d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx @@ -0,0 +1,69 @@ +import { GraphWidgetBarChart } from '@/dashboards/graphs/components/GraphWidgetBarChart'; +import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; +import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; +import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; +import { type ReactNode } from 'react'; +import { GraphSubType, WidgetType, type Widget } from '../mocks/mockWidgets'; + +type WidgetRenderer = (widget: Widget) => ReactNode; + +const widgetRenderers: Record = { + [GraphSubType.NUMBER]: (widget) => ( + + ), + [GraphSubType.GAUGE]: (widget) => ( + + ), + [GraphSubType.PIE]: (widget) => ( + + ), + [GraphSubType.BAR]: (widget) => ( + + ), +}; + +export const renderWidget = (widget: Widget): ReactNode => { + if (widget.type !== WidgetType.GRAPH) { + return null; + } + + const graphType = widget.configuration?.graphType as GraphSubType | undefined; + if (!graphType) { + return null; + } + + const renderer = widgetRenderers[graphType]; + if (!renderer) { + return null; + } + + return renderer(widget); +}; diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 9b3d974a94..98c1d678fa 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -54,5 +54,7 @@ export enum SettingsPath { RoleDetail = 'roles/:roleId', RoleObjectLevel = 'roles/:roleId/object/:objectMetadataId', RoleAddObjectLevel = 'roles/:roleId/add-object-permission', - PageLayoutEdition = 'page-layout/edition', + PageLayout = 'page-layout', + PageLayoutNew = 'page-layout/new', + PageLayoutEdit = 'page-layout/:id', } diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 5b92580519..39e0c41846 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -60,9 +60,9 @@ export const DefaultLayout = () => { const isMobile = useIsMobile(); const isSettingsPage = useIsSettingsPage(); const location = useLocation(); - const isPageLayoutEditor = location.pathname.includes( - '/settings/page-layout/edition', - ); + const isPageLayoutEditor = + location.pathname.includes('/settings/page-layout/new') || + location.pathname.match(/\/settings\/page-layout\/[^/]+$/); const theme = useTheme(); const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); diff --git a/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx b/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx deleted file mode 100644 index 9e42cd95b2..0000000000 --- a/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; -import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; -import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; -import { SettingsPageFullWidthContainer } from '@/settings/components/SettingsPageFullWidthContainer'; -import { SettingsPath } from '@/types/SettingsPath'; -import styled from '@emotion/styled'; -import { useLingui } from '@lingui/react/macro'; -import { - Responsive, - WidthProvider, - type Layout, - type Layouts, - type ResponsiveProps, -} from 'react-grid-layout'; -import 'react-grid-layout/css/styles.css'; -import 'react-resizable/css/styles.css'; -import { useRecoilState } from 'recoil'; -import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { PageLayoutWidgetPlaceholder } from './components/PageLayoutWidgetPlaceholder'; -import { mockLayouts, mockWidgets } from './mocks/mockWidgets'; -import { - pageLayoutLayoutsState, - pageLayoutWidgetsState, -} from './states/pageLayoutState'; - -const StyledGridContainer = styled.div` - background: ${({ theme }) => theme.background.secondary}; - border: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: ${({ theme }) => theme.border.radius.md}; - box-sizing: border-box; - flex: 1; - overflow-y: auto; - padding: ${({ theme }) => theme.spacing(2)}; - position: relative; - width: 100%; - - .react-grid-placeholder { - background: ${({ theme }) => theme.adaptiveColors.blue3} !important; - - border-radius: ${({ theme }) => theme.border.radius.sm}; - } -`; - -const StyledGridOverlay = styled.div` - 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: repeat(12, 1fr); - grid-auto-rows: 50px; - gap: ${({ theme }) => theme.spacing(2)}; - pointer-events: none; - z-index: 0; - - @media (max-width: 480px) { - grid-template-columns: 1fr; - } - - @media (max-width: 768px) and (min-width: 481px) { - grid-template-columns: repeat(12, 1fr); - } -`; - -const StyledGridCell = styled.div` - background: 'transparent'; - border: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: ${({ theme }) => theme.border.radius.sm}; -`; - -type ExtendedResponsiveProps = ResponsiveProps & { - maxCols?: number; - preventCollision?: boolean; -}; - -const ResponsiveGridLayout = WidthProvider( - Responsive, -) as React.ComponentType; - -export const PageLayoutEdition = () => { - const { t } = useLingui(); - const [pageLayoutWidgets, setPageLayoutWidgets] = useRecoilState( - pageLayoutWidgetsState, - ); - const [pageLayoutLayouts, setPageLayoutLayouts] = useRecoilState( - pageLayoutLayoutsState, - ); - - const handleLayoutChange = (_: Layout[], allLayouts: Layouts) => { - setPageLayoutLayouts(allLayouts); - }; - - const handleAddWidget = () => { - // temp: Populate with mock data when empty placeholder is clicked - setPageLayoutWidgets(mockWidgets); - setPageLayoutLayouts(mockLayouts); - }; - - const isEmptyState = pageLayoutWidgets.length === 0; - - const emptyLayout: Layouts = { - lg: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], - md: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], - sm: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }], - }; - - return ( - - - - {Array.from({ length: 12 * 20 }).map((_, i) => ( - - ))} - - - {isEmptyState ? ( -
- -
- ) : ( - pageLayoutWidgets.map((widget) => ( -
- - {widget.type === 'number' && ( - - )} - {widget.type === 'gauge' && ( - - )} - {widget.type === 'pie' && ( - - )} - -
- )) - )} -
-
-
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx new file mode 100644 index 0000000000..8f09ae5c5f --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx @@ -0,0 +1,311 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageFullWidthContainer } from '@/settings/components/SettingsPageFullWidthContainer'; +import { PageLayoutInitializationEffect } from '@/settings/page-layout/components/PageLayoutInitializationEffect'; +import { PageLayoutWidgetPlaceholder } from '@/settings/page-layout/components/PageLayoutWidgetPlaceholder'; +import { EMPTY_LAYOUT } from '@/settings/page-layout/constants/EmptyLayout'; +import { + PAGE_LAYOUT_CONFIG, + type PageLayoutBreakpoint, +} from '@/settings/page-layout/constants/PageLayoutBreakpoints'; +import { usePageLayoutDraftState } from '@/settings/page-layout/hooks/usePageLayoutDraftState'; +import { usePageLayoutDragSelection } from '@/settings/page-layout/hooks/usePageLayoutDragSelection'; +import { usePageLayoutHandleLayoutChange } from '@/settings/page-layout/hooks/usePageLayoutHandleLayoutChange'; +import { usePageLayoutSaveHandler } from '@/settings/page-layout/hooks/usePageLayoutSaveHandler'; +import { usePageLayoutWidgetDelete } from '@/settings/page-layout/hooks/usePageLayoutWidgetDelete'; +import { pageLayoutCurrentBreakpointState } from '@/settings/page-layout/states/pageLayoutCurrentBreakpointState'; +import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState'; +import { pageLayoutSelectedCellsState } from '@/settings/page-layout/states/pageLayoutSelectedCellsState'; +import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState'; +import { calculateTotalGridRows } from '@/settings/page-layout/utils/calculateTotalGridRows'; +import { convertLayoutsToWidgets } from '@/settings/page-layout/utils/convertLayoutsToWidgets'; +import { generateCellId } from '@/settings/page-layout/utils/generateCellId'; +import { renderWidget } from '@/settings/page-layout/utils/widgetRegistry'; +import { SettingsPath } from '@/types/SettingsPath'; +import { TitleInput } from '@/ui/input/components/TitleInput'; +import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +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 } from 'recoil'; +import { IconAppWindow, IconPlus } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; + +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 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; + +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 pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState); + const { navigateCommandMenu } = useNavigateCommandMenu(); + + const { + handleDragSelectionStart, + handleDragSelectionChange, + handleDragSelectionEnd, + } = usePageLayoutDragSelection(); + + const handleOpenAddWidget = useCallback(() => { + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutWidgetTypeSelect, + pageTitle: 'Add Widget', + pageIcon: IconAppWindow, + resetNavigationStack: true, + }); + }, [navigateCommandMenu]); + + const { handleRemoveWidget } = usePageLayoutWidgetDelete(); + const { handleLayoutChange } = usePageLayoutHandleLayoutChange(); + + const gridContainerRef = useRef(null); + + const isEmptyState = pageLayoutWidgets.length === 0; + + const gridRows = useMemo( + () => calculateTotalGridRows(pageLayoutCurrentLayouts), + [pageLayoutCurrentLayouts], + ); + + const handleCancel = () => { + navigateSettings(SettingsPath.PageLayout); + }; + + const handleSaveClick = async () => { + setIsSaving(true); + try { + const widgetsWithPositions = convertLayoutsToWidgets( + pageLayoutWidgets, + pageLayoutCurrentLayouts, + ); + + setPageLayoutDraft((prev) => ({ + ...prev, + widgets: widgetsWithPositions, + })); + + await savePageLayout(widgetsWithPositions); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + + setPageLayoutDraft((prev) => ({ ...prev, name: value })) + } + sizeVariant="md" + /> + ), + }, + ]} + actionButton={ + + + {!isEmptyState && ( +