Instant widget placeholder placement on drag end (#15496)

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 <bordeau.lucas@gmail.com>
This commit is contained in:
nitin
2025-11-03 22:38:13 +05:30
committed by GitHub
parent 9c695cfed5
commit 902eb2c5d2
21 changed files with 1545 additions and 55 deletions
@@ -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,
);
}
}
}
@@ -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<string, any> = {
__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,
@@ -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<string, any> = {
__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 };
};
@@ -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<HTMLDivElement>(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 (
<>
<StyledGridContainer ref={gridContainerRef}>
@@ -137,28 +173,26 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
onDragStop={() => {
setDraggingWidgetId(null);
}}
onLayoutChange={handleLayoutChange}
onLayoutChange={handleLayoutChangeWithoutPendingPlaceholder}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{isLayoutEmpty ? (
<div key="empty-placeholder" data-select-disable="true">
<WidgetPlaceholder />
</div>
) : (
activeTabWidgets?.map((widget) => (
<div key={widget.id} data-select-disable="true">
{gridLayoutItems.map((item) => (
<div key={item.id} data-select-disable="true">
{item.type === 'placeholder' ? (
<WidgetPlaceholder />
) : (
<WidgetRenderer
widget={widget}
widget={item.widget}
pageLayoutType={currentPageLayout.type}
layoutMode="grid"
/>
</div>
))
)}
)}
</div>
))}
</ResponsiveGridLayout>
</StyledGridContainer>
</>
@@ -0,0 +1,2 @@
export const PENDING_WIDGET_PLACEHOLDER_LAYOUT_KEY =
'__pending-widget-placeholder__';
@@ -0,0 +1,4 @@
export const WIDGET_PLACEHOLDER_SIZE = {
w: 4,
h: 4,
};
@@ -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;
@@ -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 });
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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<string, Layouts> = {
'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<string, Layouts> = {
'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<string, Layouts> = {
'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<string, Layouts> = {
'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<string, Layouts> = {};
(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<string, Layouts> = {
'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<string, Layouts> = {
'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<string, Layouts> = {
'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<string, Layouts> = {
'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);
});
});
@@ -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'),
],
});
@@ -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,
});
@@ -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,
),
});
@@ -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;
};
@@ -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,
),
});
@@ -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<string, Layouts>,
): Record<string, Layouts> => {
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,
};
};
@@ -62,7 +62,7 @@ export const WidgetPlaceholder = () => {
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
<Trans>No widgets yet</Trans>
<Trans>Add widget</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
<Trans>Click to add your first widget</Trans>