From 902eb2c5d2477ba6769f7382c2522feea7189234 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:38:13 +0530 Subject: [PATCH] Instant widget placeholder placement on drag end (#15496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes - https://discord.com/channels/1130383047699738754/1430596561137700957 fixed a bug - where on chart type change in side panel -- wont update the widgets minimum size --------- Co-authored-by: Raphaƫl Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Lucas Bordeau --- ...ommandMenuCloseAnimationCompleteCleanup.ts | 7 + .../page-layout/components/ChartSettings.tsx | 44 +-- .../hooks/useUpdateGraphTypeConfig.ts | 111 +++++++ .../components/PageLayoutGridLayout.tsx | 72 +++-- .../PendingWidgetPlaceholderLayoutKey.ts | 2 + .../constants/WidgetPlaceholderSize.ts | 4 + .../page-layout/types/GridLayoutItem.ts | 14 + .../calculateGridCellPosition.test.ts | 62 ++++ .../createDefaultGraphWidget.test.ts | 155 ++++++++++ ...PendingWidgetPlaceholderLayoutItem.test.ts | 84 ++++++ ...ilterPendingPlaceholderFromLayouts.test.ts | 145 +++++++++ ...areGridLayoutItemsWithPlaceholders.test.ts | 254 ++++++++++++++++ .../updateLayoutItemConstraints.test.ts | 220 ++++++++++++++ ...pdateWidgetMinimumSizeForGraphType.test.ts | 284 ++++++++++++++++++ .../utils/addPendingPlaceholderToLayouts.ts | 16 + ...reatePendingWidgetPlaceholderLayoutItem.ts | 20 ++ .../filterPendingPlaceholderFromLayouts.ts | 13 + .../prepareGridLayoutItemsWithPlaceholders.ts | 35 +++ .../utils/updateLayoutItemConstraints.ts | 26 ++ .../updateWidgetMinimumSizeForGraphType.ts | 30 ++ .../widgets/components/WidgetPlaceholder.tsx | 2 +- 21 files changed, 1545 insertions(+), 55 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateGraphTypeConfig.ts create mode 100644 packages/twenty-front/src/modules/page-layout/constants/PendingWidgetPlaceholderLayoutKey.ts create mode 100644 packages/twenty-front/src/modules/page-layout/constants/WidgetPlaceholderSize.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/GridLayoutItem.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/calculateGridCellPosition.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultGraphWidget.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/createPendingWidgetPlaceholderLayoutItem.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/filterPendingPlaceholderFromLayouts.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/prepareGridLayoutItemsWithPlaceholders.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/updateLayoutItemConstraints.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/updateWidgetMinimumSizeForGraphType.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/addPendingPlaceholderToLayouts.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/createPendingWidgetPlaceholderLayoutItem.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/filterPendingPlaceholderFromLayouts.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/prepareGridLayoutItemsWithPlaceholders.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/updateLayoutItemConstraints.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/updateWidgetMinimumSizeForGraphType.ts diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts index 2c4c2ddfb7d..263bbc6afb9 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts @@ -15,6 +15,7 @@ import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; @@ -85,6 +86,12 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => { }), null, ); + set( + pageLayoutDraggedAreaComponentState.atomFamily({ + instanceId: record.pageLayoutId, + }), + null, + ); } } } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx index a0491bfccde..eda2c47049a 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx @@ -5,13 +5,13 @@ import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateComm import { ChartSettingItem } from '@/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem'; import { ChartTypeSelectionSection } from '@/command-menu/pages/page-layout/components/ChartTypeSelectionSection'; import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation'; -import { GRAPH_TYPE_TO_CONFIG_TYPENAME } from '@/command-menu/pages/page-layout/constants/GraphTypeToConfigTypename'; import { useChartSettingsValues } from '@/command-menu/pages/page-layout/hooks/useChartSettingsValues'; import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; import { useUpdateChartSettingInput } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput'; import { useUpdateChartSettingToggle } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle'; import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; +import { useUpdateGraphTypeConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateGraphTypeConfig'; import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/shouldHideChartSetting'; @@ -24,14 +24,9 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; import { SidePanelInformationBanner } from 'twenty-ui/display'; -import { - AggregateOperations, - GraphType, - type PageLayoutWidget, -} from '~/generated/graphql'; +import { GraphType, type PageLayoutWidget } from '~/generated/graphql'; const StyledSidePanelInformationBanner = styled(SidePanelInformationBanner)` margin-top: ${({ theme }) => theme.spacing(2)}; @@ -69,6 +64,12 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { const { updateChartSettingInput } = useUpdateChartSettingInput(pageLayoutId); + const { updateGraphTypeConfig } = useUpdateGraphTypeConfig({ + pageLayoutId, + widget, + configuration, + }); + const isGroupByEnabled = getChartSettingsValues( CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, ); @@ -76,34 +77,7 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { useRecoilComponentState(hasWidgetTooManyGroupsComponentState); const handleGraphTypeChange = (graphType: GraphType) => { - const configToUpdate: Record = { - __typename: GRAPH_TYPE_TO_CONFIG_TYPENAME[graphType], - graphType, - }; - - if (graphType !== GraphType.AGGREGATE && graphType !== GraphType.GAUGE) { - const currentAggregateFieldMetadataId = - configuration.aggregateFieldMetadataId; - - const objectMetadataItem = objectMetadataItems.find( - (item) => item.id === widget.objectMetadataId, - ); - - if (isDefined(objectMetadataItem)) { - const aggregateField = objectMetadataItem.fields.find( - (field) => field.id === currentAggregateFieldMetadataId, - ); - - if ( - isDefined(aggregateField) && - isFieldMetadataDateKind(aggregateField.type) && - (configuration.aggregateOperation === AggregateOperations.MIN || - configuration.aggregateOperation === AggregateOperations.MAX) - ) { - configToUpdate.aggregateOperation = AggregateOperations.COUNT; - } - } - } + const configToUpdate = updateGraphTypeConfig(graphType); updateCurrentWidgetConfig({ configToUpdate, diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateGraphTypeConfig.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateGraphTypeConfig.ts new file mode 100644 index 00000000000..35f751ec5f7 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateGraphTypeConfig.ts @@ -0,0 +1,111 @@ +import { GRAPH_TYPE_TO_CONFIG_TYPENAME } from '@/command-menu/pages/page-layout/constants/GraphTypeToConfigTypename'; +import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { updateWidgetMinimumSizeForGraphType } from '@/page-layout/utils/updateWidgetMinimumSizeForGraphType'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; +import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { GraphType, type PageLayoutWidget } from '~/generated/graphql'; + +export const useUpdateGraphTypeConfig = ({ + pageLayoutId, + widget, + configuration, +}: { + pageLayoutId: string; + widget: PageLayoutWidget; + configuration: ChartConfiguration; +}) => { + const { objectMetadataItems } = useObjectMetadataItems(); + + const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(pageLayoutId); + + const activeTabIdState = useRecoilComponentCallbackState( + activeTabIdComponentState, + tabListInstanceId, + ); + + const currentlyEditingWidgetIdState = useRecoilComponentCallbackState( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + + const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const updateGraphTypeConfig = useRecoilCallback( + ({ set, snapshot }) => + (graphType: GraphType) => { + const configToUpdate: Record = { + __typename: GRAPH_TYPE_TO_CONFIG_TYPENAME[graphType], + graphType, + }; + + if ( + graphType !== GraphType.AGGREGATE && + graphType !== GraphType.GAUGE + ) { + const currentAggregateFieldMetadataId = + configuration.aggregateFieldMetadataId; + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === widget.objectMetadataId, + ); + + if (isDefined(objectMetadataItem)) { + const aggregateField = objectMetadataItem.fields.find( + (field) => field.id === currentAggregateFieldMetadataId, + ); + + if ( + isDefined(aggregateField) && + isFieldMetadataDateKind(aggregateField.type) && + (configuration.aggregateOperation === AggregateOperations.MIN || + configuration.aggregateOperation === AggregateOperations.MAX) + ) { + configToUpdate.aggregateOperation = AggregateOperations.COUNT; + } + } + } + + const activeTabId = snapshot.getLoadable(activeTabIdState).getValue(); + const currentlyEditingWidgetId = snapshot + .getLoadable(currentlyEditingWidgetIdState) + .getValue(); + + if (isDefined(activeTabId) && isDefined(currentlyEditingWidgetId)) { + const currentLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + + const updatedLayouts = updateWidgetMinimumSizeForGraphType( + graphType, + currentlyEditingWidgetId, + activeTabId, + currentLayouts, + ); + + set(pageLayoutCurrentLayoutsState, updatedLayouts); + } + + return configToUpdate; + }, + [ + activeTabIdState, + configuration, + currentlyEditingWidgetIdState, + objectMetadataItems, + pageLayoutCurrentLayoutsState, + widget.objectMetadataId, + ], + ); + + return { updateGraphTypeConfig }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx index 213de6689d8..fc92c163ae3 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx @@ -11,16 +11,22 @@ import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayo import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/pageLayoutCurrentBreakpointComponentState'; import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState'; +import { addPendingPlaceholderToLayouts } from '@/page-layout/utils/addPendingPlaceholderToLayouts'; +import { filterPendingPlaceholderFromLayouts } from '@/page-layout/utils/filterPendingPlaceholderFromLayouts'; +import { prepareGridLayoutItemsWithPlaceholders } from '@/page-layout/utils/prepareGridLayoutItemsWithPlaceholders'; import { WidgetPlaceholder } from '@/page-layout/widgets/components/WidgetPlaceholder'; import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'; 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 { useMemo, useRef } from 'react'; import { Responsive, WidthProvider, + type Layout, + type Layouts, type ResponsiveProps, } from 'react-grid-layout'; import { isDefined } from 'twenty-shared/utils'; @@ -74,6 +80,16 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { const { handleLayoutChange } = usePageLayoutHandleLayoutChange(); + const handleLayoutChangeWithoutPendingPlaceholder = ( + currentLayout: Layout[], + allLayouts: Layouts, + ) => { + handleLayoutChange( + currentLayout, + filterPendingPlaceholderFromLayouts(allLayouts), + ); + }; + const gridContainerRef = useRef(null); const isPageLayoutInEditMode = useRecoilComponentValue( @@ -84,23 +100,43 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { pageLayoutCurrentLayoutsComponentState, ); + const pageLayoutDraggedArea = useRecoilComponentValue( + pageLayoutDraggedAreaComponentState, + ); + const { currentPageLayout } = useCurrentPageLayout(); const activeTab = currentPageLayout?.tabs.find((tab) => tab.id === tabId); - if (!isDefined(currentPageLayout) || !isDefined(activeTab)) { - return null; - } - - const activeTabWidgets = activeTab.widgets; + const activeTabWidgets = activeTab?.widgets; const isLayoutEmpty = !isDefined(activeTabWidgets) || activeTabWidgets.length === 0; - const layouts = isLayoutEmpty + const hasPendingPlaceholder = + isDefined(pageLayoutDraggedArea) && !isLayoutEmpty; + + const baseLayouts = isLayoutEmpty ? EMPTY_LAYOUT : (pageLayoutCurrentLayouts[tabId] ?? EMPTY_LAYOUT); + const layouts = hasPendingPlaceholder + ? addPendingPlaceholderToLayouts(baseLayouts, pageLayoutDraggedArea) + : baseLayouts; + + const gridLayoutItems = useMemo( + () => + prepareGridLayoutItemsWithPlaceholders( + activeTabWidgets, + hasPendingPlaceholder, + ), + [activeTabWidgets, hasPendingPlaceholder], + ); + + if (!isDefined(currentPageLayout) || !isDefined(activeTab)) { + return null; + } + return ( <> @@ -137,28 +173,26 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { onDragStop={() => { setDraggingWidgetId(null); }} - onLayoutChange={handleLayoutChange} + onLayoutChange={handleLayoutChangeWithoutPendingPlaceholder} onBreakpointChange={(newBreakpoint) => setPageLayoutCurrentBreakpoint( newBreakpoint as PageLayoutBreakpoint, ) } > - {isLayoutEmpty ? ( -
- -
- ) : ( - activeTabWidgets?.map((widget) => ( -
+ {gridLayoutItems.map((item) => ( +
+ {item.type === 'placeholder' ? ( + + ) : ( -
- )) - )} + )} +
+ ))}
diff --git a/packages/twenty-front/src/modules/page-layout/constants/PendingWidgetPlaceholderLayoutKey.ts b/packages/twenty-front/src/modules/page-layout/constants/PendingWidgetPlaceholderLayoutKey.ts new file mode 100644 index 00000000000..dde822ceb0f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/PendingWidgetPlaceholderLayoutKey.ts @@ -0,0 +1,2 @@ +export const PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY = + '__pending-widget-placeholder__'; diff --git a/packages/twenty-front/src/modules/page-layout/constants/WidgetPlaceholderSize.ts b/packages/twenty-front/src/modules/page-layout/constants/WidgetPlaceholderSize.ts new file mode 100644 index 00000000000..8daa1476bb6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/constants/WidgetPlaceholderSize.ts @@ -0,0 +1,4 @@ +export const WIDGET_PLACEHOLDER_SIZE = { + w: 4, + h: 4, +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/GridLayoutItem.ts b/packages/twenty-front/src/modules/page-layout/types/GridLayoutItem.ts new file mode 100644 index 00000000000..5b2d8d1a79e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/GridLayoutItem.ts @@ -0,0 +1,14 @@ +import { type PageLayoutWidget } from '~/generated/graphql'; + +export type GridLayoutWidgetItem = { + id: string; + type: 'widget'; + widget: PageLayoutWidget; +}; + +export type GridLayoutPlaceholderItem = { + id: string; + type: 'placeholder'; +}; + +export type GridLayoutItem = GridLayoutWidgetItem | GridLayoutPlaceholderItem; diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/calculateGridCellPosition.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/calculateGridCellPosition.test.ts new file mode 100644 index 00000000000..dd5862bb01c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/calculateGridCellPosition.test.ts @@ -0,0 +1,62 @@ +import { calculateGridCellPosition } from '../calculateGridCellPosition'; + +describe('calculateGridCellPosition', () => { + it('should calculate correct position for first cell', () => { + const result = calculateGridCellPosition({ + index: 0, + numberOfColumns: 4, + }); + + expect(result).toEqual({ row: 0, column: 0 }); + }); + + it('should calculate correct position for cells in first row', () => { + const result = calculateGridCellPosition({ + index: 3, + numberOfColumns: 4, + }); + + expect(result).toEqual({ row: 0, column: 3 }); + }); + + it('should calculate correct position for cells in subsequent rows', () => { + const result = calculateGridCellPosition({ + index: 5, + numberOfColumns: 4, + }); + + expect(result).toEqual({ row: 1, column: 1 }); + }); + + it('should handle different column counts', () => { + const result2Columns = calculateGridCellPosition({ + index: 3, + numberOfColumns: 2, + }); + expect(result2Columns).toEqual({ row: 1, column: 1 }); + + const result6Columns = calculateGridCellPosition({ + index: 7, + numberOfColumns: 6, + }); + expect(result6Columns).toEqual({ row: 1, column: 1 }); + }); + + it('should handle large indices', () => { + const result = calculateGridCellPosition({ + index: 99, + numberOfColumns: 10, + }); + + expect(result).toEqual({ row: 9, column: 9 }); + }); + + it('should handle single column layout', () => { + const result = calculateGridCellPosition({ + index: 5, + numberOfColumns: 1, + }); + + expect(result).toEqual({ row: 5, column: 0 }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultGraphWidget.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultGraphWidget.test.ts new file mode 100644 index 00000000000..072ae807a7c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultGraphWidget.test.ts @@ -0,0 +1,155 @@ +import { + AggregateOperations, + AxisNameDisplay, + GraphOrderBy, + GraphType, + WidgetType, +} from '~/generated/graphql'; +import { createDefaultGraphWidget } from '../createDefaultGraphWidget'; + +describe('createDefaultGraphWidget', () => { + const baseParams = { + id: 'widget-1', + pageLayoutTabId: 'tab-1', + title: 'Test Widget', + gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 }, + objectMetadataId: 'object-1', + }; + + describe('AGGREGATE graph type', () => { + it('should create widget with aggregate configuration when field is provided', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.AGGREGATE, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + }, + }); + + expect(widget.type).toBe(WidgetType.GRAPH); + expect(widget.configuration).toEqual({ + __typename: 'AggregateChartConfiguration', + graphType: GraphType.AGGREGATE, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.COUNT, + displayDataLabel: true, + }); + }); + + it('should return null configuration when field is missing', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.AGGREGATE, + }); + + expect(widget.configuration).toBeNull(); + }); + }); + + describe('VERTICAL_BAR graph type', () => { + it('should create widget with bar chart configuration when all fields are provided', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.VERTICAL_BAR, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + groupByFieldMetadataIdX: 'field-2', + }, + }); + + expect(widget.configuration).toEqual({ + __typename: 'BarChartConfiguration', + graphType: GraphType.VERTICAL_BAR, + displayDataLabel: false, + color: 'blue', + primaryAxisGroupByFieldMetadataId: 'field-2', + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.SUM, + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + axisNameDisplay: AxisNameDisplay.NONE, + }); + }); + + it('should return null configuration when required fields are missing', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.VERTICAL_BAR, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + // Missing groupByFieldMetadataIdX + }, + }); + + expect(widget.configuration).toBeNull(); + }); + }); + + describe('HORIZONTAL_BAR graph type', () => { + it('should create widget with horizontal bar configuration', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.HORIZONTAL_BAR, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + groupByFieldMetadataIdX: 'field-2', + }, + }); + + expect(widget.configuration?.__typename).toBe('BarChartConfiguration'); + if (widget.configuration?.__typename === 'BarChartConfiguration') { + expect(widget.configuration.graphType).toBe(GraphType.HORIZONTAL_BAR); + } + }); + }); + + describe('Unsupported graph types', () => { + it.each([GraphType.PIE, GraphType.LINE, GraphType.GAUGE])( + 'should return null configuration for %s graph type', + (graphType) => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + groupByFieldMetadataIdX: 'field-2', + }, + }); + + expect(widget.configuration).toBeNull(); + }, + ); + }); + + it('should use objectMetadataId from fieldSelection over params', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.AGGREGATE, + objectMetadataId: 'object-1', + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + objectMetadataId: 'object-2', + }, + }); + + expect(widget.objectMetadataId).toBe('object-2'); + }); + + it('should set timestamps and common widget properties', () => { + const widget = createDefaultGraphWidget({ + ...baseParams, + graphType: GraphType.AGGREGATE, + fieldSelection: { + aggregateFieldMetadataId: 'field-1', + }, + }); + + expect(widget.__typename).toBe('PageLayoutWidget'); + expect(widget.id).toBe('widget-1'); + expect(widget.pageLayoutTabId).toBe('tab-1'); + expect(widget.title).toBe('Test Widget'); + expect(widget.gridPosition).toEqual(baseParams.gridPosition); + expect(widget.createdAt).toBeDefined(); + expect(widget.updatedAt).toBeDefined(); + expect(widget.deletedAt).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/createPendingWidgetPlaceholderLayoutItem.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createPendingWidgetPlaceholderLayoutItem.test.ts new file mode 100644 index 00000000000..5a82da5f1e2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createPendingWidgetPlaceholderLayoutItem.test.ts @@ -0,0 +1,84 @@ +import { DEFAULT_WIDGET_SIZE } from '@/page-layout/constants/DefaultWidgetSize'; +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { createPendingWidgetPlaceholderLayoutItem } from '../createPendingWidgetPlaceholderLayoutItem'; + +describe('createPendingWidgetPlaceholderLayoutItem', () => { + const draggedArea = { x: 2, y: 3, w: 5, h: 6 }; + + describe('desktop breakpoint', () => { + it('should use dragged area position and apply minimum constraints', () => { + const result = createPendingWidgetPlaceholderLayoutItem( + draggedArea, + 'desktop', + ); + + expect(result).toEqual({ + i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + x: 2, + y: 3, + w: 5, + h: 6, + minW: DEFAULT_WIDGET_SIZE.minimum.w, + minH: DEFAULT_WIDGET_SIZE.minimum.h, + static: true, + }); + }); + + it('should apply default size when dragged area is smaller', () => { + const smallDraggedArea = { x: 1, y: 2, w: 1, h: 1 }; + const result = createPendingWidgetPlaceholderLayoutItem( + smallDraggedArea, + 'desktop', + ); + + expect(result.w).toBe(DEFAULT_WIDGET_SIZE.default.w); + expect(result.h).toBe(DEFAULT_WIDGET_SIZE.default.h); + }); + }); + + describe('mobile breakpoint', () => { + it('should always use x=0 and w=1 for mobile', () => { + const result = createPendingWidgetPlaceholderLayoutItem( + draggedArea, + 'mobile', + ); + + expect(result).toEqual({ + i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + x: 0, + y: 3, + w: 1, + h: 6, + minW: DEFAULT_WIDGET_SIZE.minimum.w, + minH: DEFAULT_WIDGET_SIZE.minimum.h, + static: true, + }); + }); + + it('should still apply minimum height for mobile', () => { + const smallDraggedArea = { x: 5, y: 2, w: 10, h: 1 }; + const result = createPendingWidgetPlaceholderLayoutItem( + smallDraggedArea, + 'mobile', + ); + + expect(result.x).toBe(0); + expect(result.w).toBe(1); + expect(result.h).toBe(DEFAULT_WIDGET_SIZE.default.h); + }); + }); + + it('should always mark layout as static', () => { + const desktopResult = createPendingWidgetPlaceholderLayoutItem( + draggedArea, + 'desktop', + ); + const mobileResult = createPendingWidgetPlaceholderLayoutItem( + draggedArea, + 'mobile', + ); + + expect(desktopResult.static).toBe(true); + expect(mobileResult.static).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterPendingPlaceholderFromLayouts.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterPendingPlaceholderFromLayouts.test.ts new file mode 100644 index 00000000000..94135b9a368 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterPendingPlaceholderFromLayouts.test.ts @@ -0,0 +1,145 @@ +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { type Layouts } from 'react-grid-layout'; +import { filterPendingPlaceholderFromLayouts } from '../filterPendingPlaceholderFromLayouts'; + +describe('filterPendingPlaceholderFromLayouts', () => { + it('should remove pending placeholder from both desktop and mobile layouts', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 4, y: 0, w: 4, h: 4 }, + { i: 'widget-2', x: 8, y: 0, w: 4, h: 4 }, + ], + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 0, y: 4, w: 1, h: 4 }, + { i: 'widget-2', x: 0, y: 8, w: 1, h: 4 }, + ], + }; + + const result = filterPendingPlaceholderFromLayouts(layouts); + + expect(result.desktop).toHaveLength(2); + expect(result.desktop).toEqual([ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + { i: 'widget-2', x: 8, y: 0, w: 4, h: 4 }, + ]); + + expect(result.mobile).toHaveLength(2); + expect(result.mobile).toEqual([ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }, + { i: 'widget-2', x: 0, y: 8, w: 1, h: 4 }, + ]); + }); + + it('should handle layouts with no pending placeholder', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 4 }, + ], + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }, + { i: 'widget-2', x: 0, y: 4, w: 1, h: 4 }, + ], + }; + + const result = filterPendingPlaceholderFromLayouts(layouts); + + expect(result.desktop).toHaveLength(2); + expect(result.desktop).toEqual(layouts.desktop); + expect(result.mobile).toHaveLength(2); + expect(result.mobile).toEqual(layouts.mobile); + }); + + it('should handle missing desktop or mobile layouts', () => { + // Test with missing desktop property + const layoutsWithoutDesktop = { + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 0, y: 4, w: 1, h: 4 }, + ], + } as Layouts; + + const result1 = filterPendingPlaceholderFromLayouts(layoutsWithoutDesktop); + + expect(result1.desktop).toBeUndefined(); + expect(result1.mobile).toHaveLength(1); + expect(result1.mobile).toEqual([{ i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }]); + + // Test with missing mobile property + const layoutsWithoutMobile = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 4, y: 0, w: 4, h: 4 }, + ], + } as Layouts; + + const result2 = filterPendingPlaceholderFromLayouts(layoutsWithoutMobile); + + expect(result2.desktop).toHaveLength(1); + expect(result2.desktop).toEqual([ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + ]); + expect(result2.mobile).toBeUndefined(); + }); + + it('should handle empty layouts', () => { + const emptyLayouts: Layouts = { + desktop: [], + mobile: [], + }; + + const result = filterPendingPlaceholderFromLayouts(emptyLayouts); + + expect(result.desktop).toEqual([]); + expect(result.mobile).toEqual([]); + }); + + it('should handle layouts with only pending placeholder', () => { + const layouts: Layouts = { + desktop: [ + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 0, y: 0, w: 4, h: 4 }, + ], + mobile: [ + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 0, y: 0, w: 1, h: 4 }, + ], + }; + + const result = filterPendingPlaceholderFromLayouts(layouts); + + expect(result.desktop).toEqual([]); + expect(result.mobile).toEqual([]); + }); + + it('should not mutate the original layouts', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 4, y: 0, w: 4, h: 4 }, + ], + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 4 }, + { i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, x: 0, y: 4, w: 1, h: 4 }, + ], + }; + + const originalDesktopLength = layouts.desktop!.length; + const originalMobileLength = layouts.mobile!.length; + + filterPendingPlaceholderFromLayouts(layouts); + + expect(layouts.desktop).toHaveLength(originalDesktopLength); + expect(layouts.mobile).toHaveLength(originalMobileLength); + expect( + layouts.desktop!.some( + (item) => item.i === PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + ), + ).toBe(true); + expect( + layouts.mobile!.some( + (item) => item.i === PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + ), + ).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/prepareGridLayoutItemsWithPlaceholders.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/prepareGridLayoutItemsWithPlaceholders.test.ts new file mode 100644 index 00000000000..2132739c5dc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/prepareGridLayoutItemsWithPlaceholders.test.ts @@ -0,0 +1,254 @@ +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { + AggregateOperations, + GraphType, + WidgetType, + type PageLayoutWidget, +} from '~/generated/graphql'; +import { prepareGridLayoutItemsWithPlaceholders } from '../prepareGridLayoutItemsWithPlaceholders'; + +describe('prepareGridLayoutItemsWithPlaceholders', () => { + const createMockWidget = (id: string): PageLayoutWidget => ({ + id, + pageLayoutTabId: 'tab-1', + title: `Test Widget ${id}`, + type: WidgetType.GRAPH, + objectMetadataId: null, + gridPosition: { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }, + configuration: { + __typename: 'AggregateChartConfiguration', + graphType: GraphType.AGGREGATE, + aggregateOperation: AggregateOperations.COUNT, + aggregateFieldMetadataId: 'field-id', + displayDataLabel: false, + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }); + + describe('empty or undefined widgets', () => { + it('should return empty placeholder when widgets is undefined', () => { + const result = prepareGridLayoutItemsWithPlaceholders(undefined, false); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'empty-placeholder', + type: 'placeholder', + }); + }); + + it('should return empty placeholder when widgets is an empty array', () => { + const result = prepareGridLayoutItemsWithPlaceholders([], false); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'empty-placeholder', + type: 'placeholder', + }); + }); + + it('should return empty placeholder even when shouldIncludePendingPlaceholder is true', () => { + const result = prepareGridLayoutItemsWithPlaceholders([], true); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'empty-placeholder', + type: 'placeholder', + }); + }); + }); + + describe('widgets without pending placeholder', () => { + it('should return single widget item when given one widget', () => { + const widget = createMockWidget('widget-1'); + const result = prepareGridLayoutItemsWithPlaceholders([widget], false); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'widget-1', + type: 'widget', + widget, + }); + }); + + it('should return multiple widget items when given multiple widgets', () => { + const widget1 = createMockWidget('widget-1'); + const widget2 = createMockWidget('widget-2'); + const widget3 = createMockWidget('widget-3'); + const widgets = [widget1, widget2, widget3]; + + const result = prepareGridLayoutItemsWithPlaceholders(widgets, false); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + id: 'widget-1', + type: 'widget', + widget: widget1, + }); + expect(result[1]).toEqual({ + id: 'widget-2', + type: 'widget', + widget: widget2, + }); + expect(result[2]).toEqual({ + id: 'widget-3', + type: 'widget', + widget: widget3, + }); + }); + }); + + describe('widgets with pending placeholder', () => { + it('should add pending placeholder to single widget', () => { + const widget = createMockWidget('widget-1'); + const result = prepareGridLayoutItemsWithPlaceholders([widget], true); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'widget-1', + type: 'widget', + widget, + }); + expect(result[1]).toEqual({ + id: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + type: 'placeholder', + }); + }); + + it('should add pending placeholder after multiple widgets', () => { + const widget1 = createMockWidget('widget-1'); + const widget2 = createMockWidget('widget-2'); + const widgets = [widget1, widget2]; + + const result = prepareGridLayoutItemsWithPlaceholders(widgets, true); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + id: 'widget-1', + type: 'widget', + widget: widget1, + }); + expect(result[1]).toEqual({ + id: 'widget-2', + type: 'widget', + widget: widget2, + }); + expect(result[2]).toEqual({ + id: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + type: 'placeholder', + }); + }); + + it('should place pending placeholder at the end of the array', () => { + const widgets = Array.from({ length: 5 }, (_, i) => + createMockWidget(`widget-${i + 1}`), + ); + + const result = prepareGridLayoutItemsWithPlaceholders(widgets, true); + + expect(result).toHaveLength(6); + // Check that the last item is the pending placeholder + const lastItem = result[result.length - 1]; + expect(lastItem).toEqual({ + id: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + type: 'placeholder', + }); + // Check that all other items are widgets + for (let i = 0; i < result.length - 1; i++) { + expect(result[i].type).toBe('widget'); + } + }); + }); + + describe('type and structure verification', () => { + it('should set correct types for widget items', () => { + const widget = createMockWidget('widget-1'); + const result = prepareGridLayoutItemsWithPlaceholders([widget], false); + + const item = result[0]; + expect(item.type).toBe('widget'); + expect(item).toHaveProperty('widget'); + expect(item.id).toBe(widget.id); + if (item.type === 'widget') { + expect(item.widget).toBe(widget); + } + }); + + it('should set correct types for placeholder items', () => { + const result = prepareGridLayoutItemsWithPlaceholders(undefined, false); + + const item = result[0]; + expect(item.type).toBe('placeholder'); + expect(item).not.toHaveProperty('widget'); + expect(item.id).toBe('empty-placeholder'); + }); + + it('should set correct types for pending placeholder', () => { + const widget = createMockWidget('widget-1'); + const result = prepareGridLayoutItemsWithPlaceholders([widget], true); + + const pendingPlaceholder = result[1]; + expect(pendingPlaceholder.type).toBe('placeholder'); + expect(pendingPlaceholder).not.toHaveProperty('widget'); + expect(pendingPlaceholder.id).toBe(PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY); + }); + + it('should preserve all widget properties', () => { + const widget = createMockWidget('widget-1'); + const result = prepareGridLayoutItemsWithPlaceholders([widget], false); + + if (result[0].type === 'widget') { + const resultWidget = result[0].widget; + expect(resultWidget.id).toBe(widget.id); + expect(resultWidget.pageLayoutTabId).toBe(widget.pageLayoutTabId); + expect(resultWidget.title).toBe(widget.title); + expect(resultWidget.type).toBe(widget.type); + expect(resultWidget.objectMetadataId).toBe(widget.objectMetadataId); + expect(resultWidget.gridPosition).toEqual(widget.gridPosition); + expect(resultWidget.configuration).toEqual(widget.configuration); + expect(resultWidget.createdAt).toBe(widget.createdAt); + expect(resultWidget.updatedAt).toBe(widget.updatedAt); + expect(resultWidget.deletedAt).toBe(widget.deletedAt); + } + }); + }); + + describe('immutability', () => { + it('should not mutate the input widgets array', () => { + const widget1 = createMockWidget('widget-1'); + const widget2 = createMockWidget('widget-2'); + const originalWidgets = [widget1, widget2]; + const widgetsCopy = [...originalWidgets]; + + prepareGridLayoutItemsWithPlaceholders(originalWidgets, true); + + // Check that the array wasn't mutated + expect(originalWidgets).toHaveLength(2); + expect(originalWidgets).toEqual(widgetsCopy); + expect(originalWidgets[0]).toBe(widget1); + expect(originalWidgets[1]).toBe(widget2); + }); + + it('should not mutate individual widget objects', () => { + const widget = createMockWidget('widget-1'); + const originalWidget = { ...widget }; + + prepareGridLayoutItemsWithPlaceholders([widget], true); + + expect(widget).toEqual(originalWidget); + }); + + it('should return a new array instance', () => { + const widgets = [createMockWidget('widget-1')]; + const result = prepareGridLayoutItemsWithPlaceholders(widgets, false); + + expect(result).not.toBe(widgets); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateLayoutItemConstraints.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateLayoutItemConstraints.test.ts new file mode 100644 index 00000000000..91c3d0c877b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateLayoutItemConstraints.test.ts @@ -0,0 +1,220 @@ +import { type Layouts } from 'react-grid-layout'; +import { updateLayoutItemConstraints } from '../updateLayoutItemConstraints'; + +describe('updateLayoutItemConstraints', () => { + it('should update constraints for the specified layout item in desktop layout', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + ], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 3, + minH: 4, + }); + + expect(result.desktop?.[0]).toEqual({ + i: 'widget-1', + x: 0, + y: 0, + w: 4, + h: 3, + minW: 3, + minH: 4, + }); + expect(result.desktop?.[1]).toEqual({ + i: 'widget-2', + x: 4, + y: 0, + w: 4, + h: 3, + minW: 2, + minH: 2, + }); + }); + + it('should update constraints for the specified layout item in mobile layout', () => { + const layouts: Layouts = { + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 2, h: 2, minW: 1, minH: 1 }, + { i: 'widget-2', x: 0, y: 2, w: 2, h: 2, minW: 1, minH: 1 }, + ], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-2', { + minW: 2, + minH: 3, + }); + + expect(result.mobile?.[0]).toEqual({ + i: 'widget-1', + x: 0, + y: 0, + w: 2, + h: 2, + minW: 1, + minH: 1, + }); + expect(result.mobile?.[1]).toEqual({ + i: 'widget-2', + x: 0, + y: 2, + w: 2, + h: 2, + minW: 2, + minH: 3, + }); + }); + + it('should update constraints in both desktop and mobile layouts when item exists in both', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + ], + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 2, h: 2, minW: 1, minH: 1 }, + { i: 'widget-2', x: 0, y: 2, w: 2, h: 2, minW: 1, minH: 1 }, + ], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 5, + minH: 6, + }); + + expect(result.desktop?.[0].minW).toBe(5); + expect(result.desktop?.[0].minH).toBe(6); + expect(result.mobile?.[0].minW).toBe(5); + expect(result.mobile?.[0].minH).toBe(6); + }); + + it('should not modify layouts when layout item does not exist', () => { + const layouts: Layouts = { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }], + mobile: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2, minW: 1, minH: 1 }], + }; + + const result = updateLayoutItemConstraints(layouts, 'non-existent', { + minW: 10, + minH: 10, + }); + + expect(result.desktop).toEqual(layouts.desktop); + expect(result.mobile).toEqual(layouts.mobile); + }); + + it('should handle undefined desktop layout', () => { + const layouts: Layouts = { + mobile: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2, minW: 1, minH: 1 }], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 3, + minH: 3, + }); + + expect(result.desktop).toBeUndefined(); + expect(result.mobile?.[0].minW).toBe(3); + expect(result.mobile?.[0].minH).toBe(3); + }); + + it('should handle undefined mobile layout', () => { + const layouts: Layouts = { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 4, + minH: 5, + }); + + expect(result.desktop?.[0].minW).toBe(4); + expect(result.desktop?.[0].minH).toBe(5); + expect(result.mobile).toBeUndefined(); + }); + + it('should handle empty layouts object', () => { + const layouts: Layouts = {}; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 3, + minH: 3, + }); + + expect(result).toEqual({}); + }); + + it('should preserve other properties of layout items', () => { + const layouts: Layouts = { + desktop: [ + { + i: 'widget-1', + x: 0, + y: 0, + w: 4, + h: 3, + minW: 2, + minH: 2, + static: true, + isDraggable: false, + }, + ], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 5, + minH: 5, + }); + + expect(result.desktop?.[0]).toEqual({ + i: 'widget-1', + x: 0, + y: 0, + w: 4, + h: 3, + minW: 5, + minH: 5, + static: true, + isDraggable: false, + }); + }); + + it('should handle layouts with multiple widgets and only update the target', () => { + const layouts: Layouts = { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 3, minW: 2, minH: 2 }, + { i: 'widget-3', x: 0, y: 3, w: 8, h: 2, minW: 3, minH: 1 }, + ], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-2', { + minW: 6, + minH: 4, + }); + + expect(result.desktop?.[0].minW).toBe(2); + expect(result.desktop?.[0].minH).toBe(2); + expect(result.desktop?.[1].minW).toBe(6); + expect(result.desktop?.[1].minH).toBe(4); + expect(result.desktop?.[2].minW).toBe(3); + expect(result.desktop?.[2].minH).toBe(1); + }); + + it('should handle constraints with zero values', () => { + const layouts: Layouts = { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 2, minH: 2 }], + }; + + const result = updateLayoutItemConstraints(layouts, 'widget-1', { + minW: 0, + minH: 0, + }); + + expect(result.desktop?.[0].minW).toBe(0); + expect(result.desktop?.[0].minH).toBe(0); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateWidgetMinimumSizeForGraphType.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateWidgetMinimumSizeForGraphType.test.ts new file mode 100644 index 00000000000..0cad47a3ac4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/updateWidgetMinimumSizeForGraphType.test.ts @@ -0,0 +1,284 @@ +import { type Layouts } from 'react-grid-layout'; +import { GraphType } from '~/generated/graphql'; +import { getWidgetSize } from '../getWidgetSize'; +import { updateLayoutItemConstraints } from '../updateLayoutItemConstraints'; +import { updateWidgetMinimumSizeForGraphType } from '../updateWidgetMinimumSizeForGraphType'; + +jest.mock('../getWidgetSize'); +jest.mock('../updateLayoutItemConstraints'); + +describe('updateWidgetMinimumSizeForGraphType', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update widget minimum size for a valid graph type and existing tab', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + 'tab-2': { + desktop: [{ i: 'widget-2', x: 0, y: 0, w: 4, h: 3 }], + }, + }; + + const updatedLayouts: Layouts = { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 3, minH: 2 }], + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue(updatedLayouts); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.PIE, + 'widget-1', + 'tab-1', + mockLayouts, + ); + + expect(getWidgetSize).toHaveBeenCalledWith(GraphType.PIE, 'minimum'); + expect(updateLayoutItemConstraints).toHaveBeenCalledWith( + mockLayouts['tab-1'], + 'widget-1', + { minW: 3, minH: 2 }, + ); + expect(result).toEqual({ + 'tab-1': updatedLayouts, + 'tab-2': mockLayouts['tab-2'], + }); + }); + + it('should return original layouts when tab does not exist', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.VERTICAL_BAR, + 'widget-2', + 'non-existent-tab', + mockLayouts, + ); + + expect(getWidgetSize).toHaveBeenCalledWith( + GraphType.VERTICAL_BAR, + 'minimum', + ); + expect(updateLayoutItemConstraints).not.toHaveBeenCalled(); + expect(result).toEqual(mockLayouts); + }); + + it('should handle different graph types correctly', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + }; + + const graphTypes = [ + GraphType.AGGREGATE, + GraphType.GAUGE, + GraphType.PIE, + GraphType.VERTICAL_BAR, + GraphType.HORIZONTAL_BAR, + GraphType.LINE, + ]; + + graphTypes.forEach((graphType, index) => { + const mockSize = { w: index + 2, h: index + 1 }; + (getWidgetSize as jest.Mock).mockReturnValue(mockSize); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue({ + desktop: [ + { + i: 'widget-1', + x: 0, + y: 0, + w: 4, + h: 3, + minW: mockSize.w, + minH: mockSize.h, + }, + ], + }); + + const result = updateWidgetMinimumSizeForGraphType( + graphType, + 'widget-1', + 'tab-1', + mockLayouts, + ); + + expect(getWidgetSize).toHaveBeenCalledWith(graphType, 'minimum'); + expect(updateLayoutItemConstraints).toHaveBeenCalledWith( + mockLayouts['tab-1'], + 'widget-1', + { minW: mockSize.w, minH: mockSize.h }, + ); + expect(result['tab-1']).toBeDefined(); + }); + }); + + it('should preserve other tabs when updating a specific tab', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + 'tab-2': { + desktop: [{ i: 'widget-2', x: 0, y: 0, w: 5, h: 4 }], + }, + 'tab-3': { + desktop: [{ i: 'widget-3', x: 0, y: 0, w: 6, h: 5 }], + }, + }; + + const updatedTab2Layouts: Layouts = { + desktop: [{ i: 'widget-2', x: 0, y: 0, w: 5, h: 4, minW: 4, minH: 3 }], + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 4, h: 3 }); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue( + updatedTab2Layouts, + ); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.LINE, + 'widget-2', + 'tab-2', + mockLayouts, + ); + + expect(result).toEqual({ + 'tab-1': mockLayouts['tab-1'], + 'tab-2': updatedTab2Layouts, + 'tab-3': mockLayouts['tab-3'], + }); + }); + + it('should handle empty layouts object', () => { + const mockLayouts: Record = {}; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.PIE, + 'widget-1', + 'tab-1', + mockLayouts, + ); + + expect(updateLayoutItemConstraints).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('should handle layouts with both desktop and mobile', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + mobile: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 }], + }, + }; + + const updatedLayouts: Layouts = { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3, minW: 3, minH: 2 }], + mobile: [{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2, minW: 3, minH: 2 }], + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue(updatedLayouts); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.GAUGE, + 'widget-1', + 'tab-1', + mockLayouts, + ); + + expect(result['tab-1']).toEqual(updatedLayouts); + }); + + it('should handle widget not found in tab layouts', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + }; + + const unchangedLayouts = mockLayouts['tab-1']; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue( + unchangedLayouts, + ); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.PIE, + 'widget-non-existent', + 'tab-1', + mockLayouts, + ); + + expect(updateLayoutItemConstraints).toHaveBeenCalledWith( + mockLayouts['tab-1'], + 'widget-non-existent', + { minW: 3, minH: 2 }, + ); + expect(result['tab-1']).toEqual(unchangedLayouts); + }); + + it('should call updateLayoutItemConstraints with correct parameters', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 3 }, + ], + }, + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 5, h: 4 }); + (updateLayoutItemConstraints as jest.Mock).mockReturnValue({ + desktop: [ + { i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }, + { i: 'widget-2', x: 4, y: 0, w: 4, h: 3, minW: 5, minH: 4 }, + ], + }); + + updateWidgetMinimumSizeForGraphType( + GraphType.HORIZONTAL_BAR, + 'widget-2', + 'tab-1', + mockLayouts, + ); + + expect(updateLayoutItemConstraints).toHaveBeenCalledWith( + mockLayouts['tab-1'], + 'widget-2', + { minW: 5, minH: 4 }, + ); + }); + + it('should handle undefined tab layouts gracefully', () => { + const mockLayouts: Record = { + 'tab-1': { + desktop: [{ i: 'widget-1', x: 0, y: 0, w: 4, h: 3 }], + }, + 'tab-2': undefined as any, // Simulating an edge case + }; + + (getWidgetSize as jest.Mock).mockReturnValue({ w: 3, h: 2 }); + + const result = updateWidgetMinimumSizeForGraphType( + GraphType.PIE, + 'widget-1', + 'tab-2', + mockLayouts, + ); + + expect(updateLayoutItemConstraints).not.toHaveBeenCalled(); + expect(result).toEqual(mockLayouts); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/addPendingPlaceholderToLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/addPendingPlaceholderToLayouts.ts new file mode 100644 index 00000000000..54d6651d078 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/addPendingPlaceholderToLayouts.ts @@ -0,0 +1,16 @@ +import { createPendingWidgetPlaceholderLayoutItem } from '@/page-layout/utils/createPendingWidgetPlaceholderLayoutItem'; +import { type Layouts } from 'react-grid-layout'; + +export const addPendingPlaceholderToLayouts = ( + baseLayouts: Layouts, + draggedArea: { x: number; y: number; w: number; h: number }, +): Layouts => ({ + desktop: [ + ...(baseLayouts.desktop ?? []), + createPendingWidgetPlaceholderLayoutItem(draggedArea, 'desktop'), + ], + mobile: [ + ...(baseLayouts.mobile ?? []), + createPendingWidgetPlaceholderLayoutItem(draggedArea, 'mobile'), + ], +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/createPendingWidgetPlaceholderLayoutItem.ts b/packages/twenty-front/src/modules/page-layout/utils/createPendingWidgetPlaceholderLayoutItem.ts new file mode 100644 index 00000000000..ef28184d1e4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/createPendingWidgetPlaceholderLayoutItem.ts @@ -0,0 +1,20 @@ +import { DEFAULT_WIDGET_SIZE } from '@/page-layout/constants/DefaultWidgetSize'; +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { type Layout } from 'react-grid-layout'; + +export const createPendingWidgetPlaceholderLayoutItem = ( + draggedArea: { x: number; y: number; w: number; h: number }, + breakpoint: 'desktop' | 'mobile', +): Layout => ({ + i: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + x: breakpoint === 'mobile' ? 0 : draggedArea.x, + y: draggedArea.y, + w: + breakpoint === 'mobile' + ? 1 + : Math.max(draggedArea.w, DEFAULT_WIDGET_SIZE.default.w), + h: Math.max(draggedArea.h, DEFAULT_WIDGET_SIZE.default.h), + minW: DEFAULT_WIDGET_SIZE.minimum.w, + minH: DEFAULT_WIDGET_SIZE.minimum.h, + static: true, +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/filterPendingPlaceholderFromLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/filterPendingPlaceholderFromLayouts.ts new file mode 100644 index 00000000000..25f5e1db0e2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/filterPendingPlaceholderFromLayouts.ts @@ -0,0 +1,13 @@ +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { type Layouts } from 'react-grid-layout'; + +export const filterPendingPlaceholderFromLayouts = ( + layouts: Layouts, +): Layouts => ({ + desktop: layouts.desktop?.filter( + (layoutItem) => layoutItem.i !== PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + ), + mobile: layouts.mobile?.filter( + (layoutItem) => layoutItem.i !== PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + ), +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/prepareGridLayoutItemsWithPlaceholders.ts b/packages/twenty-front/src/modules/page-layout/utils/prepareGridLayoutItemsWithPlaceholders.ts new file mode 100644 index 00000000000..6c97fd8fb38 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/prepareGridLayoutItemsWithPlaceholders.ts @@ -0,0 +1,35 @@ +import { PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY } from '@/page-layout/constants/PendingWidgetPlaceholderLayoutKey'; +import { type GridLayoutItem } from '@/page-layout/types/GridLayoutItem'; +import { isDefined } from 'twenty-shared/utils'; +import { type PageLayoutWidget } from '~/generated/graphql'; + +export const prepareGridLayoutItemsWithPlaceholders = ( + widgets: PageLayoutWidget[] | undefined, + shouldIncludePendingPlaceholder: boolean, +): GridLayoutItem[] => { + const hasWidgets = isDefined(widgets) && widgets.length > 0; + + if (!hasWidgets) { + return [ + { + id: 'empty-placeholder', + type: 'placeholder' as const, + }, + ]; + } + + const gridLayoutItems: GridLayoutItem[] = widgets.map((widget) => ({ + id: widget.id, + type: 'widget' as const, + widget, + })); + + if (shouldIncludePendingPlaceholder) { + gridLayoutItems.push({ + id: PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY, + type: 'placeholder' as const, + }); + } + + return gridLayoutItems; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/updateLayoutItemConstraints.ts b/packages/twenty-front/src/modules/page-layout/utils/updateLayoutItemConstraints.ts new file mode 100644 index 00000000000..ea704b3dd12 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/updateLayoutItemConstraints.ts @@ -0,0 +1,26 @@ +import { type Layouts } from 'react-grid-layout'; + +export const updateLayoutItemConstraints = ( + layouts: Layouts, + layoutItemId: string, + constraints: { minW: number; minH: number }, +): Layouts => ({ + desktop: layouts.desktop?.map((layoutItem) => + layoutItem.i === layoutItemId + ? { + ...layoutItem, + minW: constraints.minW, + minH: constraints.minH, + } + : layoutItem, + ), + mobile: layouts.mobile?.map((layoutItem) => + layoutItem.i === layoutItemId + ? { + ...layoutItem, + minW: constraints.minW, + minH: constraints.minH, + } + : layoutItem, + ), +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/updateWidgetMinimumSizeForGraphType.ts b/packages/twenty-front/src/modules/page-layout/utils/updateWidgetMinimumSizeForGraphType.ts new file mode 100644 index 00000000000..69bbfc2f76f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/updateWidgetMinimumSizeForGraphType.ts @@ -0,0 +1,30 @@ +import { getWidgetSize } from '@/page-layout/utils/getWidgetSize'; +import { updateLayoutItemConstraints } from '@/page-layout/utils/updateLayoutItemConstraints'; +import { type GraphType } from '~/generated/graphql'; +import { type Layouts } from 'react-grid-layout'; +import { isDefined } from 'twenty-shared/utils'; + +export const updateWidgetMinimumSizeForGraphType = ( + graphType: GraphType, + widgetId: string, + tabId: string, + currentLayouts: Record, +): Record => { + const minimumSize = getWidgetSize(graphType, 'minimum'); + const currentTabLayouts = currentLayouts[tabId]; + + if (!isDefined(currentTabLayouts)) { + return currentLayouts; + } + + const updatedTabLayouts = updateLayoutItemConstraints( + currentTabLayouts, + widgetId, + { minW: minimumSize.w, minH: minimumSize.h }, + ); + + return { + ...currentLayouts, + [tabId]: updatedTabLayouts, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx index 1698d9f253b..1e8e9a98b1d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx @@ -62,7 +62,7 @@ export const WidgetPlaceholder = () => { - No widgets yet + Add widget Click to add your first widget