Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b56f2e9e | ||
|
|
956419dc60 | ||
|
|
ad0662ddb1 |
+172
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+193
@@ -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);
|
||||
});
|
||||
});
|
||||
+81
@@ -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,
|
||||
|
||||
+37
-1
@@ -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": {
|
||||
|
||||
+51
@@ -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({
|
||||
|
||||
+173
-1
@@ -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;
|
||||
|
||||
+110
-1
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user