add validation on widgets grid position and sizing (#15397)

closes https://github.com/twentyhq/core-team-issues/issues/1606

As discussed in DMS -- overlapping is not a concern since the library
handles the collision and handles overlapping widgets (if created
through api) using the compact type vertical (ie, move the widget
vertically to create space)
This commit is contained in:
nitin
2025-10-29 19:35:26 +05:30
committed by GitHub
parent 473efb5dc5
commit 28c6edfa1f
7 changed files with 286 additions and 5 deletions
@@ -0,0 +1 @@
export const WIDGET_GRID_MAX_COLUMNS = 12;
@@ -0,0 +1 @@
export const WIDGET_GRID_MAX_ROWS = 100;
@@ -1,31 +1,31 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsNumber, Min } from 'class-validator';
import { IsInt, IsNotEmpty, Min } from 'class-validator';
import { GridPosition } from 'src/engine/core-modules/page-layout/types/grid-position.type';
@InputType('GridPositionInput')
export class GridPositionInput implements GridPosition {
@Field()
@IsNumber()
@IsInt()
@Min(0)
@IsNotEmpty()
row: number;
@Field()
@IsNumber()
@IsInt()
@Min(0)
@IsNotEmpty()
column: number;
@Field()
@IsNumber()
@IsInt()
@Min(1)
@IsNotEmpty()
rowSpan: number;
@Field()
@IsNumber()
@IsInt()
@Min(1)
@IsNotEmpty()
columnSpan: number;
@@ -14,6 +14,7 @@ export enum PageLayoutWidgetExceptionMessageKey {
PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND',
PAGE_LAYOUT_WIDGET_NOT_DELETED = 'PAGE_LAYOUT_WIDGET_NOT_DELETED',
GRID_POSITION_REQUIRED = 'GRID_POSITION_REQUIRED',
INVALID_WIDGET_GRID_POSITION = 'INVALID_WIDGET_GRID_POSITION',
INVALID_WIDGET_CONFIGURATION = 'INVALID_WIDGET_CONFIGURATION',
}
@@ -38,6 +39,15 @@ export const generatePageLayoutWidgetExceptionMessage = (
return 'Page layout widget is not deleted and cannot be restored';
case PageLayoutWidgetExceptionMessageKey.GRID_POSITION_REQUIRED:
return 'Grid position is required';
case PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION:
if (widgetTitle && detailedError) {
return `Invalid grid position for widget "${widgetTitle}": ${detailedError}`;
}
if (detailedError) {
return `Invalid grid position: ${detailedError}`;
}
return 'Invalid widget grid position';
case PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION:
if (widgetTitle && widgetType && detailedError) {
return `Invalid configuration for widget "${widgetTitle}" of type ${widgetType}: ${detailedError}`;
@@ -21,6 +21,7 @@ import {
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
import { validateWidgetGridPosition } from 'src/engine/core-modules/page-layout/utils/validate-widget-grid-position.util';
@Injectable()
export class PageLayoutWidgetService {
@@ -115,6 +116,11 @@ export class PageLayoutWidgetService {
);
}
validateWidgetGridPosition(
pageLayoutWidgetData.gridPosition,
pageLayoutWidgetData.title,
);
try {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidgetData.pageLayoutTabId,
@@ -209,6 +215,12 @@ export class PageLayoutWidgetService {
);
}
if (updateData.gridPosition) {
const titleForValidation = updateData.title ?? existingWidget.title;
validateWidgetGridPosition(updateData.gridPosition, titleForValidation);
}
let validatedConfig: WidgetConfigurationInterface | null = null;
if (updateData.configuration) {
@@ -0,0 +1,187 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-rows.constant';
import { PageLayoutWidgetException } from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { validateWidgetGridPosition } from 'src/engine/core-modules/page-layout/utils/validate-widget-grid-position.util';
describe('validateWidgetGridPosition', () => {
const validGridPosition = {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
};
describe('Valid grid positions', () => {
it('should not throw for valid grid position', () => {
expect(() =>
validateWidgetGridPosition(validGridPosition, 'Test Widget'),
).not.toThrow();
});
it('should not throw for widget at max column boundary', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: WIDGET_GRID_MAX_COLUMNS - 1,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget at max row boundary', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 1,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget spanning to column grid edge', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 8,
rowSpan: 1,
columnSpan: 4,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget spanning to row grid edge', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 5,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
'Test Widget',
),
).not.toThrow();
});
});
describe('Invalid row positions', () => {
it('should throw for row exceeding max rows', () => {
expect(() =>
validateWidgetGridPosition(
{ ...validGridPosition, row: WIDGET_GRID_MAX_ROWS },
'Test Widget',
),
).toThrow(PageLayoutWidgetException);
});
it('should throw when widget extends beyond grid height', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 2,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
'Test Widget',
),
).toThrow(/extends beyond grid height/);
});
});
describe('Invalid column positions', () => {
it('should throw for column exceeding max columns', () => {
expect(() =>
validateWidgetGridPosition(
{ ...validGridPosition, column: WIDGET_GRID_MAX_COLUMNS },
'Test Widget',
),
).toThrow(PageLayoutWidgetException);
});
});
describe('Widget extending beyond grid', () => {
it('should throw when widget extends beyond grid width', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 10,
rowSpan: 1,
columnSpan: 3,
},
'Test Widget',
),
).toThrow(/extends beyond grid width/);
});
});
describe('Error messages', () => {
it('should include max columns value in error', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 10,
rowSpan: 1,
columnSpan: 5,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_COLUMNS.toString()));
});
it('should include max rows value in error for row start', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS + 10,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_ROWS.toString()));
});
it('should include max rows value in error for row extension', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 95,
column: 0,
rowSpan: 10,
columnSpan: 6,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_ROWS.toString()));
});
it('should include widget title in error message', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'My Custom Widget',
),
).toThrow(/My Custom Widget/);
});
});
});
@@ -0,0 +1,70 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-rows.constant';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
type GridPosition = {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
export const validateWidgetGridPosition = (
gridPosition: GridPosition,
widgetTitle: string,
): void => {
const { row, column, rowSpan, columnSpan } = gridPosition;
if (column >= WIDGET_GRID_MAX_COLUMNS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`column ${column} exceeds grid width (max column is ${WIDGET_GRID_MAX_COLUMNS - 1})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (column + columnSpan > WIDGET_GRID_MAX_COLUMNS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`widget extends beyond grid width (column ${column} + columnSpan ${columnSpan} > ${WIDGET_GRID_MAX_COLUMNS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (row >= WIDGET_GRID_MAX_ROWS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`row ${row} exceeds maximum allowed rows (${WIDGET_GRID_MAX_ROWS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (row + rowSpan > WIDGET_GRID_MAX_ROWS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`widget extends beyond grid height (row ${row} + rowSpan ${rowSpan} > ${WIDGET_GRID_MAX_ROWS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
};