Compare commits

...
Author SHA1 Message Date
ehconitin 63b56f2e9e Add editable property comparison guardrail 2026-05-26 17:03:19 +05:30
ehconitin 956419dc60 Enforce CANVAS tab widget cap 2026-05-26 17:02:08 +05:30
ehconitin ad0662ddb1 Fix page layout widget tab moves 2026-05-26 16:36:26 +05:30
8 changed files with 818 additions and 4 deletions
@@ -0,0 +1,172 @@
import { ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
import { FLAT_AGENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-agent/constants/flat-agent-editable-properties.constant';
import { FLAT_APPLICATION_VARIABLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-application-variable/constants/flat-application-variable-editable-properties.constant';
import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant';
import { FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-connection-provider/constants/flat-connection-provider-editable-properties.constant';
import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant';
import { FLAT_FIELD_PERMISSION_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-permission/constants/flat-field-permission-editable-properties.constant';
import { FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant';
import { FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant';
import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant';
import { FLAT_OBJECT_PERMISSION_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-permission/constants/flat-object-permission-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout/constants/flat-page-layout-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-widget/constants/flat-page-layout-widget-editable-properties.constant';
import { FLAT_PERMISSION_FLAG_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-permission-flag/constants/flat-permission-flag-editable-properties.constant';
import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant';
import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role-target/constants/flat-role-target-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-group-editable-properties.constant';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { FLAT_VIEW_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/flat-view-editable-properties.constant';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field-group/constants/flat-view-field-group-editable-properties.constant';
import { FLAT_VIEW_FILTER_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter/constants/flat-view-filter-editable-properties.constant';
import { FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter-group/constants/flat-view-filter-group-editable-properties.constant';
import { FLAT_VIEW_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-group/constants/flat-view-group-editable-properties.constant';
import { FLAT_VIEW_SORT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-sort/constants/flat-view-sort-editable-properties.constant';
import { FLAT_WEBHOOK_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-webhook/constants/flat-webhook-editable-properties.constant';
import { FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/logic-function/constants/flat-logic-function-editable-properties.constant';
type MetadataName =
keyof typeof ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME;
type EditablePropertiesByMetadataName = Partial<
Record<MetadataName, readonly string[]>
>;
type EditablePropertiesComparisonAllowList = Partial<
Record<MetadataName, Record<string, string>>
>;
const uniqueProperties = (properties: readonly string[]) => [
...new Set(properties),
];
const combineProperties = (
...propertiesByType: readonly (readonly string[])[]
) => uniqueProperties(propertiesByType.flat());
const EDITABLE_PROPERTIES_BY_METADATA_NAME = {
agent: FLAT_AGENT_EDITABLE_PROPERTIES,
applicationVariable: FLAT_APPLICATION_VARIABLE_EDITABLE_PROPERTIES,
commandMenuItem: FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES,
connectionProvider: FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES,
fieldMetadata: combineProperties(
FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.custom,
FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.standard,
),
fieldPermission: FLAT_FIELD_PERMISSION_EDITABLE_PROPERTIES,
frontComponent: FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES,
logicFunction: FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES,
navigationMenuItem: FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES,
objectMetadata: combineProperties(
FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.custom,
FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.standard,
),
objectPermission: FLAT_OBJECT_PERMISSION_EDITABLE_PROPERTIES,
pageLayout: FLAT_PAGE_LAYOUT_EDITABLE_PROPERTIES,
pageLayoutTab: FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
pageLayoutWidget: FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
permissionFlag: FLAT_PERMISSION_FLAG_EDITABLE_PROPERTIES,
role: FLAT_ROLE_EDITABLE_PROPERTIES,
roleTarget: FLAT_ROLE_TARGET_EDITABLE_PROPERTIES,
rowLevelPermissionPredicate:
FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES,
rowLevelPermissionPredicateGroup:
FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES,
skill: FLAT_SKILL_EDITABLE_PROPERTIES,
view: FLAT_VIEW_EDITABLE_PROPERTIES,
viewField: FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
viewFieldGroup: FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES,
viewFilter: FLAT_VIEW_FILTER_EDITABLE_PROPERTIES,
viewFilterGroup: FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES,
viewGroup: FLAT_VIEW_GROUP_EDITABLE_PROPERTIES,
viewSort: FLAT_VIEW_SORT_EDITABLE_PROPERTIES,
webhook: FLAT_WEBHOOK_EDITABLE_PROPERTIES,
} as const satisfies EditablePropertiesByMetadataName;
const EDITABLE_PROPERTIES_ALLOWED_TO_SKIP_COMPARISON: EditablePropertiesComparisonAllowList =
{
commandMenuItem: {
pageLayoutId:
'Existing parent-link exception; command menu item page layout moves need a separate product and migration decision.',
},
navigationMenuItem: {
pageLayoutId:
'Existing parent-link exception; navigation menu item page layout moves need a separate product and migration decision.',
},
pageLayoutTab: {
layoutMode:
'Existing exposed field whose migration semantics were already disabled before this guardrail; decide separately whether tab layout-mode updates should persist.',
},
};
const getPropertyConfiguration = ({
metadataName,
propertyName,
}: {
metadataName: MetadataName;
propertyName: string;
}) => {
const propertyConfigurationByName =
ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME[
metadataName
] as Record<string, { toCompare: boolean } | undefined>;
return propertyConfigurationByName[propertyName];
};
describe('editable flat entity properties configuration', () => {
it('keeps editable properties comparable unless explicitly allowlisted', () => {
const nonComparableEditableProperties = Object.entries(
EDITABLE_PROPERTIES_BY_METADATA_NAME,
).flatMap(([metadataName, editableProperties]) =>
editableProperties.flatMap((propertyName) => {
const propertyConfiguration = getPropertyConfiguration({
metadataName: metadataName as MetadataName,
propertyName,
});
const allowListReason =
EDITABLE_PROPERTIES_ALLOWED_TO_SKIP_COMPARISON[
metadataName as MetadataName
]?.[propertyName];
if (
propertyConfiguration?.toCompare === false &&
allowListReason === undefined
) {
return [`${metadataName}.${propertyName}`];
}
return [];
}),
);
expect(nonComparableEditableProperties).toEqual([]);
});
it('keeps comparison allowlist entries current and documented', () => {
const staleAllowListEntries = Object.entries(
EDITABLE_PROPERTIES_ALLOWED_TO_SKIP_COMPARISON,
).flatMap(([metadataName, propertyReasons]) =>
Object.entries(propertyReasons).flatMap(([propertyName, reason]) => {
const propertyConfiguration = getPropertyConfiguration({
metadataName: metadataName as MetadataName,
propertyName,
});
if (
reason.trim().length === 0 ||
propertyConfiguration?.toCompare !== false
) {
return [`${metadataName}.${propertyName}`];
}
return [];
}),
);
expect(staleAllowListEntries).toEqual([]);
});
});
@@ -930,7 +930,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
},
pageLayoutTabId: {
toCompare: false,
toCompare: true,
toStringify: false,
universalProperty: 'pageLayoutTabUniversalIdentifier',
isOverridable: true,
@@ -0,0 +1,193 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
import { FlatPageLayoutWidgetValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service';
const CANVAS_TAB_UI = '00000000-0000-0000-0000-000000000aa1';
const GRID_TAB_UI = '00000000-0000-0000-0000-000000000aa2';
const WIDGET_A_UI = '00000000-0000-0000-0000-000000000111';
const WIDGET_B_UI = '00000000-0000-0000-0000-000000000222';
const CANVAS_CAP_ERROR = /CANVAS layout tab can only contain one widget/;
const tab = (
layoutMode: PageLayoutTabLayoutMode,
universalIdentifier: string,
): UniversalFlatPageLayoutTab =>
({
universalIdentifier,
layoutMode,
}) as unknown as UniversalFlatPageLayoutTab;
const widget = (
universalIdentifier: string,
pageLayoutTabUniversalIdentifier: string,
options?: {
universalOverrides?: {
pageLayoutTabUniversalIdentifier?: string;
} | null;
},
): FlatPageLayoutWidget =>
({
universalIdentifier,
pageLayoutTabUniversalIdentifier,
universalOverrides: options?.universalOverrides ?? null,
title: 'widget',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
position: { layoutMode: PageLayoutTabLayoutMode.CANVAS },
}) as unknown as FlatPageLayoutWidget;
const mapsFrom = (entities: { universalIdentifier: string }[]): any => {
const maps = createEmptyFlatEntityMaps() as any;
for (const entity of entities) {
maps.byUniversalIdentifier[entity.universalIdentifier] = entity;
}
return maps;
};
const buildCreateArgs = ({
incoming,
tabs,
existingWidgets,
}: {
incoming: FlatPageLayoutWidget;
tabs: UniversalFlatPageLayoutTab[];
existingWidgets: FlatPageLayoutWidget[];
}) =>
({
flatEntityToValidate: incoming,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutTabMaps: mapsFrom(tabs),
flatPageLayoutWidgetMaps: mapsFrom(existingWidgets),
},
additionalCacheDataMaps: { featureFlagsMap: {} },
workspaceId: 'workspace-id',
buildOptions: {} as never,
}) as any;
const buildUpdateArgs = ({
universalIdentifier,
update,
tabs,
existingWidgets,
}: {
universalIdentifier: string;
update: Record<string, unknown>;
tabs: UniversalFlatPageLayoutTab[];
existingWidgets: FlatPageLayoutWidget[];
}) =>
({
universalIdentifier,
flatEntityUpdate: update,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutTabMaps: mapsFrom(tabs),
flatPageLayoutWidgetMaps: mapsFrom(existingWidgets),
},
additionalCacheDataMaps: { featureFlagsMap: {} },
workspaceId: 'workspace-id',
buildOptions: {} as never,
}) as any;
const errorsMatching = (errors: { message: string }[], matcher: RegExp) =>
errors.filter((error) => matcher.test(error.message));
describe('FlatPageLayoutWidgetValidatorService — CANVAS widget cap', () => {
let service: FlatPageLayoutWidgetValidatorService;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
FlatPageLayoutWidgetValidatorService,
{
provide: FlatPageLayoutWidgetTypeValidatorService,
useValue: {
validateFlatPageLayoutWidgetTypeSpecificitiesForCreation: () => [],
validateFlatPageLayoutWidgetTypeSpecificitiesForUpdate: () => [],
},
},
],
}).compile();
service = moduleRef.get(FlatPageLayoutWidgetValidatorService);
});
it('rejects a second widget on a CANVAS tab', async () => {
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
const result = await service.validateFlatPageLayoutWidgetCreation(
buildCreateArgs({
incoming: widget(WIDGET_B_UI, CANVAS_TAB_UI),
tabs: [canvasTab],
existingWidgets: [widget(WIDGET_A_UI, CANVAS_TAB_UI)],
}),
);
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
});
it('counts widgets assigned to a CANVAS tab through overrides', async () => {
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
const result = await service.validateFlatPageLayoutWidgetCreation(
buildCreateArgs({
incoming: widget(WIDGET_B_UI, CANVAS_TAB_UI),
tabs: [canvasTab, gridTab],
existingWidgets: [
widget(WIDGET_A_UI, GRID_TAB_UI, {
universalOverrides: {
pageLayoutTabUniversalIdentifier: CANVAS_TAB_UI,
},
}),
],
}),
);
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
});
it('rejects moving a widget into an occupied CANVAS tab', async () => {
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
const result = await service.validateFlatPageLayoutWidgetUpdate(
buildUpdateArgs({
universalIdentifier: WIDGET_B_UI,
update: {
universalOverrides: {
pageLayoutTabUniversalIdentifier: CANVAS_TAB_UI,
},
},
tabs: [canvasTab, gridTab],
existingWidgets: [
widget(WIDGET_A_UI, CANVAS_TAB_UI),
widget(WIDGET_B_UI, GRID_TAB_UI),
],
}),
);
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
});
it('does not apply the cap to non-CANVAS tabs', async () => {
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
const result = await service.validateFlatPageLayoutWidgetCreation(
buildCreateArgs({
incoming: widget(WIDGET_B_UI, GRID_TAB_UI),
tabs: [gridTab],
existingWidgets: [widget(WIDGET_A_UI, GRID_TAB_UI)],
}),
);
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(0);
});
});
@@ -13,10 +13,12 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
import { PageLayoutTabExceptionCode } from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
import { PageLayoutWidgetExceptionCode } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
import { validatePageLayoutWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-page-layout-widget-grid-position.util';
import { validatePageLayoutWidgetVerticalListPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-page-layout-widget-vertical-list-position.util';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-grid-position.util';
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
import { type UniversalFlatPageLayoutWidget } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-widget.type';
import {
FailedFlatEntityValidation,
FlatEntityValidationError,
@@ -83,6 +85,23 @@ export class FlatPageLayoutWidgetValidatorService {
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutTabMaps,
});
const effectivePageLayoutTab = findFlatEntityByUniversalIdentifier({
universalIdentifier: this.getEffectivePageLayoutTabUniversalIdentifier(
updatedFlatPageLayoutWidget,
),
flatEntityMaps:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutTabMaps,
});
const canvasTabErrors = this.validateCanvasTabWidgetCap({
pageLayoutTab: effectivePageLayoutTab,
flatPageLayoutWidgetMaps:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps,
excludeWidgetUniversalIdentifier: universalIdentifier,
});
validationResult.errors.push(...canvasTabErrors);
const gridPositionErrors = this.validateGridPosition({
gridPosition: updatedFlatPageLayoutWidget.gridPosition,
widgetTitle: updatedFlatPageLayoutWidget.title,
@@ -203,6 +222,16 @@ export class FlatPageLayoutWidgetValidatorService {
});
}
const canvasTabErrors = this.validateCanvasTabWidgetCap({
pageLayoutTab: referencedPageLayoutTab,
flatPageLayoutWidgetMaps:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps,
excludeWidgetUniversalIdentifier:
flatPageLayoutWidgetToValidate.universalIdentifier,
});
validationResult.errors.push(...canvasTabErrors);
const gridPositionErrors = this.validateGridPosition({
gridPosition: flatPageLayoutWidgetToValidate.gridPosition,
widgetTitle: flatPageLayoutWidgetToValidate.title,
@@ -235,6 +264,58 @@ export class FlatPageLayoutWidgetValidatorService {
return validationResult;
}
private validateCanvasTabWidgetCap({
pageLayoutTab,
flatPageLayoutWidgetMaps,
excludeWidgetUniversalIdentifier,
}: {
pageLayoutTab: UniversalFlatPageLayoutTab | undefined;
flatPageLayoutWidgetMaps: MetadataUniversalFlatEntityMaps<'pageLayoutWidget'>;
excludeWidgetUniversalIdentifier?: string;
}): FlatEntityValidationError[] {
if (
!isDefined(pageLayoutTab) ||
pageLayoutTab.layoutMode !== PageLayoutTabLayoutMode.CANVAS
) {
return [];
}
const existingWidgetsOnTab = Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
).filter(
(widget) =>
isDefined(widget) &&
!isDefined(widget.deletedAt) &&
this.getEffectivePageLayoutTabUniversalIdentifier(widget) ===
pageLayoutTab.universalIdentifier &&
widget.universalIdentifier !== excludeWidgetUniversalIdentifier,
);
if (existingWidgetsOnTab.length === 0) {
return [];
}
return [
{
code: PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
message: t`A CANVAS layout tab can only contain one widget`,
userFriendlyMessage: msg`A CANVAS layout tab can only contain one widget`,
},
];
}
private getEffectivePageLayoutTabUniversalIdentifier(
widget: Pick<
UniversalFlatPageLayoutWidget,
'pageLayoutTabUniversalIdentifier' | 'universalOverrides'
>,
): string {
return (
widget.universalOverrides?.pageLayoutTabUniversalIdentifier ??
widget.pageLayoutTabUniversalIdentifier
);
}
private validateGridPosition({
gridPosition,
widgetTitle,
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Page layout widget creation should fail AGGREGATE_CHART widget configuration validation failures when AGGREGATE_CHART configuration has invalid UUID 1`] = `
{
@@ -44,6 +44,42 @@ exports[`Page layout widget creation should fail BAR_CHART widget configuration
}
`;
exports[`Page layout widget creation should fail CANVAS tab widget cap rejects a second widget on a CANVAS tab 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"pageLayoutWidget": [
{
"errors": [
{
"code": "INVALID_PAGE_LAYOUT_WIDGET_DATA",
"message": "A CANVAS layout tab can only contain one widget",
"userFriendlyMessage": "A CANVAS layout tab can only contain one widget",
},
],
"flatEntityMinimalInformation": {
"pageLayoutTabUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "pageLayoutWidget",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 pageLayoutWidget",
"summary": {
"pageLayoutWidget": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A CANVAS layout tab can only contain one widget",
},
"message": "Multiple validation errors occurred while creating page layout widget",
"name": "GraphQLError",
}
`;
exports[`Page layout widget creation should fail Edge case configuration validation failures when configuration has missing configurationType 1`] = `
{
"extensions": {
@@ -16,6 +16,7 @@ import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-l
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@@ -257,6 +258,56 @@ describe('Page layout widget creation should fail', () => {
});
});
describe('CANVAS tab widget cap', () => {
let canvasTabId: string;
beforeAll(async () => {
const { data } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'CANVAS Tab For Cap Test',
pageLayoutId: testPageLayoutId,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
});
canvasTabId = data.createPageLayoutTab.id;
await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'First widget on CANVAS tab',
pageLayoutTabId: canvasTabId,
type: WidgetType.IFRAME,
configuration: TEST_IFRAME_CONFIG,
gridPosition: DEFAULT_GRID_POSITION,
},
});
});
afterAll(async () => {
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: canvasTabId },
});
});
it('rejects a second widget on a CANVAS tab', async () => {
const { errors } = await createOnePageLayoutWidget({
expectToFail: true,
input: {
title: 'Second widget should be rejected',
pageLayoutTabId: canvasTabId,
type: WidgetType.IFRAME,
configuration: TEST_IFRAME_CONFIG,
gridPosition: DEFAULT_GRID_POSITION,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});
describe('Edge case configuration validation failures', () => {
it('when configuration is null', async () => {
const { errors } = await createOnePageLayoutWidget({
@@ -11,7 +11,11 @@ import {
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { updateOnePageLayoutWithTabsAndWidgets } from 'test/integration/metadata/suites/page-layout/utils/update-one-page-layout-with-tabs-and-widgets.util';
import { AggregateOperations, FieldMetadataType } from 'twenty-shared/types';
import {
AggregateOperations,
FieldMetadataType,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -58,6 +62,174 @@ describe('Page layout with tabs update should fail', () => {
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('when moving a widget into an occupied CANVAS tab', async () => {
const { data: layoutData } = await createOnePageLayout({
expectToFail: false,
input: {
name: 'Move Into Occupied Canvas Layout',
type: PageLayoutType.RECORD_PAGE,
},
});
const testPageLayoutId = layoutData.createPageLayout.id;
const sourceTabId = v4();
const canvasTabId = v4();
const widgetToMoveId = v4();
const canvasWidgetId = v4();
try {
await updateOnePageLayoutWithTabsAndWidgets({
expectToFail: false,
input: {
id: testPageLayoutId,
name: 'Move Into Occupied Canvas Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs: [
{
id: sourceTabId,
title: 'Source Tab',
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
id: widgetToMoveId,
pageLayoutTabId: sourceTabId,
title: 'Widget To Move',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://example.com/source',
},
},
],
},
{
id: canvasTabId,
title: 'Canvas Tab',
position: 1,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
id: canvasWidgetId,
pageLayoutTabId: canvasTabId,
title: 'Canvas Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
configuration: {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://example.com/canvas',
},
},
],
},
],
},
});
const { errors } = await updateOnePageLayoutWithTabsAndWidgets({
expectToFail: true,
input: {
id: testPageLayoutId,
name: 'Move Into Occupied Canvas Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs: [
{
id: sourceTabId,
title: 'Source Tab',
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [],
},
{
id: canvasTabId,
title: 'Canvas Tab',
position: 1,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
id: canvasWidgetId,
pageLayoutTabId: canvasTabId,
title: 'Canvas Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
configuration: {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://example.com/canvas',
},
},
{
id: widgetToMoveId,
pageLayoutTabId: canvasTabId,
title: 'Widget To Move',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
configuration: {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://example.com/source',
},
},
],
},
],
},
});
expect(errors).toBeDefined();
expect(errors).toHaveLength(1);
const [firstError] = errors ?? [];
expect(firstError?.extensions.code).toBe('BAD_USER_INPUT');
expect(firstError?.message).toContain(
'A CANVAS layout tab can only contain one widget',
);
} finally {
await destroyOnePageLayout({
expectToFail: false,
input: { id: testPageLayoutId },
});
}
});
describe('chart filter validation failures', () => {
let testFieldMetadataIds: TestFieldMetadataIds;
let testPageLayoutId: string | undefined;
@@ -8,7 +8,10 @@ import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { AggregateOperations } from 'twenty-shared/types';
import {
AggregateOperations,
PageLayoutTabLayoutMode,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@@ -229,4 +232,110 @@ describe('Page layout with tabs update should succeed', () => {
);
},
);
it('should move a widget to another tab when saving layout tabs and widgets', async () => {
const widgetId = v4();
await updateOnePageLayoutWithTabsAndWidgets({
expectToFail: false,
input: {
id: testPageLayoutId,
name: 'Layout Before Widget Move',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs: [
{
id: testTabId1,
title: 'Source Tab',
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
id: widgetId,
pageLayoutTabId: testTabId1,
title: 'Iframe Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: MOCK_IFRAME_CONFIGURATION,
},
],
},
{
id: testTabId2,
title: 'Destination Tab',
position: 1,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [],
},
],
},
});
const { data } = await updateOnePageLayoutWithTabsAndWidgets({
expectToFail: false,
input: {
id: testPageLayoutId,
name: 'Layout After Widget Move',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
tabs: [
{
id: testTabId1,
title: 'Source Tab',
position: 0,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [],
},
{
id: testTabId2,
title: 'Destination Tab',
position: 1,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
id: widgetId,
pageLayoutTabId: testTabId2,
title: 'Iframe Widget',
type: WidgetType.IFRAME,
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: MOCK_IFRAME_CONFIGURATION,
},
],
},
],
},
});
const updatedTabs = data.updatePageLayoutWithTabsAndWidgets.tabs ?? [];
const sourceTab = updatedTabs.find((tab) => tab.id === testTabId1);
const destinationTab = updatedTabs.find((tab) => tab.id === testTabId2);
expect(sourceTab?.widgets).toHaveLength(0);
expect(destinationTab?.widgets).toHaveLength(1);
expect(destinationTab?.widgets?.[0]).toMatchObject({
id: widgetId,
pageLayoutTabId: testTabId2,
title: 'Iframe Widget',
});
});
});