Implement page layout override (#18472)

This commit is contained in:
Weiko
2026-03-09 18:21:08 +01:00
committed by GitHub
parent 309fd7a526
commit c433b2b73f
90 changed files with 1270 additions and 57 deletions
@@ -145,8 +145,12 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"title",
"position",
"deletedAt",
"icon",
"overrides",
],
"propertiesToStringify": [
"overrides",
],
"propertiesToStringify": [],
},
"pageLayoutWidget": {
"propertiesToCompare": [
@@ -157,11 +161,15 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"position",
"universalConfiguration",
"deletedAt",
"conditionalDisplay",
"overrides",
],
"propertiesToStringify": [
"gridPosition",
"position",
"universalConfiguration",
"conditionalDisplay",
"overrides",
],
},
"role": {
@@ -34,6 +34,7 @@ type MetadataEntityPropertyConfiguration<
: HasObjectInUnion<MetadataEntity<TMetadataName>[K]>
: boolean;
toCompare: boolean;
isOverridable?: boolean;
};
};
@@ -834,6 +835,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
type: { toCompare: true, toStringify: false, universalProperty: undefined },
objectMetadataId: {
@@ -850,6 +852,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: true,
universalProperty: undefined,
isOverridable: true,
},
configuration: {
toCompare: true,
@@ -877,7 +880,13 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'pageLayoutTabUniversalIdentifier',
},
conditionalDisplay: {
toCompare: false,
toCompare: true,
toStringify: true,
universalProperty: undefined,
isOverridable: true,
},
overrides: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
@@ -887,11 +896,13 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
position: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
deletedAt: {
toCompare: true,
@@ -899,9 +910,10 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
},
icon: {
toCompare: false,
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
createdAt: {
toCompare: false,
@@ -923,6 +935,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
overrides: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
},
skill: {
name: { toCompare: true, toStringify: false, universalProperty: undefined },
@@ -1341,3 +1358,12 @@ export type MetadataEntityComparablePropertyName<T extends AllMetadataName> =
FilterComparableKeys<
(typeof ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME)[T]
>;
type FilterOverridableKeys<TConfig> = {
[P in keyof TConfig]: TConfig[P] extends { isOverridable: true } ? P : never;
}[keyof TConfig];
export type MetadataEntityOverridablePropertyName<T extends AllMetadataName> =
FilterOverridableKeys<
(typeof ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME)[T]
>;
@@ -0,0 +1,32 @@
import {
ALL_METADATA_NAME,
type AllMetadataName,
} from 'twenty-shared/metadata';
import {
ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME,
type MetadataEntityOverridablePropertyName,
} from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
const computeOverridableProperties = <T extends AllMetadataName>(
metadataName: T,
): MetadataEntityOverridablePropertyName<T>[] => {
const config =
ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME[metadataName];
return (Object.entries(config) as [string, { isOverridable?: boolean }][])
.filter(([_, conf]) => conf.isOverridable === true)
.map(([property]) => property as MetadataEntityOverridablePropertyName<T>);
};
export const ALL_OVERRIDABLE_PROPERTIES_BY_METADATA_NAME = Object.values(
ALL_METADATA_NAME,
).reduce(
(acc, metadataName) => ({
...acc,
[metadataName]: computeOverridableProperties(metadataName),
}),
{} as {
[P in AllMetadataName]: MetadataEntityOverridablePropertyName<P>[];
},
);
@@ -3,4 +3,5 @@ import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/fla
export const FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES = [
'title',
'position',
'icon',
] as const satisfies MetadataEntityPropertyName<'pageLayoutTab'>[];
@@ -57,5 +57,6 @@ export const fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate = ({
widgetUniversalIdentifiers: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
overrides: null,
};
};
@@ -13,6 +13,8 @@ import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export type UpdatePageLayoutTabInputWithId = {
@@ -23,9 +25,13 @@ export type UpdatePageLayoutTabInputWithId = {
export const fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow = ({
updatePageLayoutTabInput: rawUpdatePageLayoutTabInput,
flatPageLayoutTabMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
updatePageLayoutTabInput: UpdatePageLayoutTabInputWithId;
flatPageLayoutTabMaps: FlatPageLayoutTabMaps;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): FlatPageLayoutTab => {
const { id: pageLayoutTabToUpdateId } = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutTabInput,
@@ -44,14 +50,32 @@ export const fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow = ({
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
const editableProperties = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutTabInput.update,
FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutTabToUpdate,
properties: [...FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES],
update: updatedEditableFieldProperties,
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatPageLayoutTabToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: existingFlatPageLayoutTabToUpdate,
updatedEditableProperties: editableProperties,
shouldOverride,
});
return {
...mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutTabToUpdate,
properties: [...FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES],
update: updatedEditableProperties,
}),
overrides,
} as FlatPageLayoutTab;
};
@@ -53,5 +53,6 @@ export const transformPageLayoutTabEntityToFlatPageLayoutTab = ({
widgetUniversalIdentifiers: pageLayoutTabEntity.widgets.map(
(widget) => widget.universalIdentifier,
),
overrides: pageLayoutTabEntity.overrides ?? null,
};
};
@@ -7,4 +7,5 @@ export const FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES = [
'gridPosition',
'position',
'configuration',
'conditionalDisplay',
] as const satisfies MetadataEntityPropertyName<'pageLayoutWidget'>[];
@@ -73,6 +73,7 @@ export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
conditionalDisplay: null,
overrides: null,
universalConfiguration:
fromPageLayoutWidgetConfigurationToUniversalConfiguration({
configuration: createPageLayoutWidgetInput.configuration,
@@ -17,6 +17,8 @@ import {
PageLayoutWidgetExceptionCode,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { validateWidgetConfigurationInput } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-configuration-input.util';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export type UpdatePageLayoutWidgetInputWithId = {
@@ -33,9 +35,13 @@ export const fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThro
flatFrontComponentMaps,
flatViewFieldGroupMaps,
flatViewMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
updatePageLayoutWidgetInput: UpdatePageLayoutWidgetInputWithId;
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
} & Pick<
AllFlatEntityMaps,
| 'flatObjectMetadataMaps'
@@ -62,29 +68,44 @@ export const fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThro
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
const editableProperties = extractAndSanitizeObjectStringFields(
rawUpdatePageLayoutWidgetInput.update,
FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
);
if (
Object.prototype.hasOwnProperty.call(
updatedEditableFieldProperties,
'configuration',
)
Object.prototype.hasOwnProperty.call(editableProperties, 'configuration')
) {
validateWidgetConfigurationInput({
configuration: updatedEditableFieldProperties.configuration,
configuration: editableProperties.configuration,
});
}
const flatPageLayoutWidgetToUpdate = mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutWidgetToUpdate,
properties: FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatPageLayoutWidgetToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
if (updatedEditableFieldProperties.objectMetadataId !== undefined) {
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'pageLayoutWidget',
existingFlatEntity: existingFlatPageLayoutWidgetToUpdate,
updatedEditableProperties: editableProperties,
shouldOverride,
});
const flatPageLayoutWidgetToUpdate = {
...mergeUpdateInExistingRecord({
existing: existingFlatPageLayoutWidgetToUpdate,
properties: FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES,
update: updatedEditableProperties,
}),
overrides,
};
if (updatedEditableProperties.objectMetadataId !== undefined) {
const { objectMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'pageLayoutWidget',
@@ -98,7 +119,7 @@ export const fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThro
objectMetadataUniversalIdentifier;
}
if (isDefined(updatedEditableFieldProperties.configuration)) {
if (isDefined(updatedEditableProperties.configuration)) {
flatPageLayoutWidgetToUpdate.universalConfiguration =
fromPageLayoutWidgetConfigurationToUniversalConfiguration({
configuration: flatPageLayoutWidgetToUpdate.configuration,
@@ -97,6 +97,7 @@ const buildFieldsWidget = ({
},
},
universalConfiguration: null,
overrides: null,
});
const buildFlatPageLayoutWidgetMaps = (
@@ -70,6 +70,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
deletedAt: null,
icon: tabProps.icon,
layoutMode: tabProps.layoutMode,
overrides: null,
});
const isFieldsWidget = widgetKey === 'fields';
@@ -120,6 +121,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
updatedAt: now,
deletedAt: null,
conditionalDisplay: null,
overrides: null,
});
}
@@ -5,6 +5,7 @@ import {
IsArray,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
ValidateNested,
@@ -29,6 +30,11 @@ export class UpdatePageLayoutTabWithWidgetsInput {
@IsNotEmpty()
position: number;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
icon?: string | null;
@Field(() => [UpdatePageLayoutWidgetWithIdInput])
@IsArray()
@ValidateNested({ each: true })
@@ -13,4 +13,9 @@ export class UpdatePageLayoutTabInput {
@IsNumber()
@IsOptional()
position?: number;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
icon?: string | null;
}
@@ -1,9 +1,16 @@
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Field,
Float,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type PageLayoutTabOverrides } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
registerEnumType(PageLayoutTabLayoutMode, {
@@ -47,4 +54,10 @@ export class PageLayoutTabDTO {
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
@Field(() => Boolean, { nullable: false })
isOverridden: boolean;
@HideField()
overrides?: PageLayoutTabOverrides | null;
}
@@ -17,7 +17,13 @@ import {
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type PageLayoutTabOverrides = {
title?: string;
position?: number;
icon?: string | null;
};
@Entity({ name: 'pageLayoutTab', schema: 'core' })
@ObjectType('PageLayoutTab')
@@ -27,7 +33,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
{ where: '"deletedAt" IS NULL' },
)
export class PageLayoutTabEntity
extends SyncableEntity
extends OverridableEntity<PageLayoutTabOverrides>
implements Required<PageLayoutTabEntity>
{
@PrimaryGeneratedColumn('uuid')
@@ -7,6 +7,7 @@ import {
import {
Args,
Context,
Float,
Mutation,
Parent,
Query,
@@ -30,6 +31,7 @@ import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout-tab/dt
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service';
import { resolvePageLayoutTabTitle } from 'src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@MetadataResolver(() => PageLayoutTabDTO)
@@ -48,15 +50,26 @@ export class PageLayoutTabResolver {
@Parent() tab: PageLayoutTabDTO,
@Context() context: I18nContext,
): Promise<string> {
const resolvedTitle = resolveOverridableEntityProperty(tab, 'title');
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolvePageLayoutTabTitle({
title: tab.title,
title: resolvedTitle,
applicationId: tab.applicationId,
i18nInstance: i18n,
});
}
@ResolveField(() => Float)
position(@Parent() tab: PageLayoutTabDTO): number {
return resolveOverridableEntityProperty(tab, 'position');
}
@ResolveField(() => String, { nullable: true })
icon(@Parent() tab: PageLayoutTabDTO): string | null | undefined {
return resolveOverridableEntityProperty(tab, 'icon');
}
@Query(() => [PageLayoutTabDTO])
@UseGuards(NoPermissionGuard)
async getPageLayoutTabs(
@@ -233,6 +233,10 @@ export class PageLayoutTabService {
fromUpdatePageLayoutTabInputToFlatPageLayoutTabToUpdateOrThrow({
updatePageLayoutTabInput,
flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout-tab/dtos/page-layout-tab.dto';
@@ -14,6 +16,8 @@ export const fromFlatPageLayoutTabToPageLayoutTabDto = (
return {
...rest,
isOverridden:
isDefined(rest.overrides) && Object.keys(rest.overrides).length > 0,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
@@ -11,7 +11,10 @@ import {
ValidateNested,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { PageLayoutWidgetPosition } from 'twenty-shared/types';
import {
PageLayoutWidgetConditionalDisplay,
PageLayoutWidgetPosition,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { GridPositionInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/grid-position.input';
@@ -60,4 +63,9 @@ export class UpdatePageLayoutWidgetWithIdInput {
@IsObject()
@IsOptional()
configuration: AllPageLayoutWidgetConfiguration;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
conditionalDisplay?: PageLayoutWidgetConditionalDisplay | null;
}
@@ -10,7 +10,10 @@ import {
ValidateNested,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { PageLayoutWidgetPosition } from 'twenty-shared/types';
import {
PageLayoutWidgetConditionalDisplay,
PageLayoutWidgetPosition,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { GridPositionInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/grid-position.input';
@@ -49,4 +52,9 @@ export class UpdatePageLayoutWidgetInput {
@IsObject()
@IsOptional()
configuration?: AllPageLayoutWidgetConfiguration;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
conditionalDisplay?: PageLayoutWidgetConditionalDisplay | null;
}
@@ -1,4 +1,9 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Field,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import GraphQLJSON from 'graphql-type-json';
@@ -8,6 +13,7 @@ import {
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type PageLayoutWidgetOverrides } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutWidgetPositionUnion } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget-position.union';
import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
@@ -67,4 +73,10 @@ export class PageLayoutWidgetDTO {
@Field(() => Date, { nullable: true })
deletedAt?: Date;
@Field(() => Boolean, { nullable: false })
isOverridden: boolean;
@HideField()
overrides?: PageLayoutWidgetOverrides | null;
}
@@ -23,9 +23,15 @@ import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { PageLayoutWidgetConfigurationTypeSettings } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configuration.type';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
export type PageLayoutWidgetOverrides = {
title?: string;
position?: PageLayoutWidgetPosition | null;
conditionalDisplay?: PageLayoutWidgetConditionalDisplay | null;
};
@Entity({ name: 'pageLayoutWidget', schema: 'core' })
@ObjectType('PageLayoutWidget')
@Index(
@@ -38,7 +44,7 @@ export class PageLayoutWidgetEntity<
TWidgetConfigurationType extends
WidgetConfigurationType = WidgetConfigurationType,
>
extends SyncableEntity
extends OverridableEntity<PageLayoutWidgetOverrides>
implements Required<PageLayoutWidgetEntity>
{
@PrimaryGeneratedColumn('uuid')
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -21,6 +22,7 @@ import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-wid
import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@MetadataResolver(() => PageLayoutWidgetDTO)
@@ -95,6 +97,21 @@ export class PageLayoutWidgetResolver {
});
}
@ResolveField(() => String)
title(@Parent() widget: PageLayoutWidgetDTO): string {
return resolveOverridableEntityProperty(widget, 'title');
}
@ResolveField(() => GraphQLJSON, { nullable: true })
position(@Parent() widget: PageLayoutWidgetDTO) {
return resolveOverridableEntityProperty(widget, 'position');
}
@ResolveField(() => GraphQLJSON, { nullable: true })
conditionalDisplay(@Parent() widget: PageLayoutWidgetDTO) {
return resolveOverridableEntityProperty(widget, 'conditionalDisplay');
}
@ResolveField(() => WidgetConfiguration, { nullable: true })
configuration(@Parent() widget: PageLayoutWidgetDTO) {
return widget.configuration;
@@ -327,24 +327,32 @@ export class PageLayoutWidgetService {
existingFlatPageLayoutWidgetMaps,
);
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatFrontComponentMaps: existingFlatFrontComponentMaps,
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
flatViewMaps: existingFlatViewMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
const [
{
workspaceId,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatFrontComponentMaps',
'flatViewFieldGroupMaps',
'flatViewMaps',
],
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatFrontComponentMaps: existingFlatFrontComponentMaps,
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
flatViewMaps: existingFlatViewMaps,
},
);
{ workspaceCustomFlatApplication },
] = await Promise.all([
this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatFrontComponentMaps',
'flatViewFieldGroupMaps',
'flatViewMaps',
],
},
),
this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
),
]);
const isConfigurationBeingUpdated = Object.prototype.hasOwnProperty.call(
updateData,
@@ -377,6 +385,10 @@ export class PageLayoutWidgetService {
flatFrontComponentMaps: existingFlatFrontComponentMaps,
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
flatViewMaps: existingFlatViewMaps,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const shouldValidateChartFields =
@@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
@@ -9,6 +11,8 @@ export const fromFlatPageLayoutWidgetToPageLayoutWidgetDto = (
return {
...rest,
isOverridden:
isDefined(rest.overrides) && Object.keys(rest.overrides).length > 0,
objectMetadataId: objectMetadataId ?? undefined,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
@@ -295,6 +295,7 @@ export class PageLayoutUpdateService {
widgetUniversalIdentifiers: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
overrides: null,
};
},
);
@@ -498,6 +499,7 @@ export class PageLayoutUpdateService {
applicationUniversalIdentifier:
workspaceCustomApplicationUniversalIdentifier,
conditionalDisplay: null,
overrides: null,
universalConfiguration:
fromPageLayoutWidgetConfigurationToUniversalConfiguration({
configuration: widgetInput.configuration,
@@ -0,0 +1,37 @@
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
const CUSTOM_APP_ID = 'custom-app-universal-id';
const STANDARD_APP_ID = 'standard-app-universal-id';
const OTHER_APP_ID = 'other-app-universal-id';
describe('isCallerOverridingEntity', () => {
it('should return true when custom app updates a standard-app entity', () => {
expect(
isCallerOverridingEntity({
callerApplicationUniversalIdentifier: CUSTOM_APP_ID,
entityApplicationUniversalIdentifier: STANDARD_APP_ID,
workspaceCustomApplicationUniversalIdentifier: CUSTOM_APP_ID,
}),
).toBe(true);
});
it('should return false when custom app updates its own entity', () => {
expect(
isCallerOverridingEntity({
callerApplicationUniversalIdentifier: CUSTOM_APP_ID,
entityApplicationUniversalIdentifier: CUSTOM_APP_ID,
workspaceCustomApplicationUniversalIdentifier: CUSTOM_APP_ID,
}),
).toBe(false);
});
it('should return false when a non-custom app updates another app entity', () => {
expect(
isCallerOverridingEntity({
callerApplicationUniversalIdentifier: OTHER_APP_ID,
entityApplicationUniversalIdentifier: STANDARD_APP_ID,
workspaceCustomApplicationUniversalIdentifier: CUSTOM_APP_ID,
}),
).toBe(false);
});
});
@@ -0,0 +1,73 @@
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
type TestEntity = {
title: string;
position: number;
icon: string | null;
overrides?: Partial<TestEntity> | null;
};
describe('resolveOverridableEntityProperty', () => {
it('should return override value when override exists for the property', () => {
const entity: TestEntity = {
title: 'Base Title',
position: 0,
icon: null,
overrides: { title: 'Overridden Title' },
};
expect(resolveOverridableEntityProperty(entity, 'title')).toBe(
'Overridden Title',
);
});
it('should return base value when overrides is null', () => {
const entity: TestEntity = {
title: 'Base Title',
position: 0,
icon: null,
overrides: null,
};
expect(resolveOverridableEntityProperty(entity, 'title')).toBe(
'Base Title',
);
});
it('should return base value when overrides exist but not for the requested property', () => {
const entity: TestEntity = {
title: 'Base Title',
position: 0,
icon: null,
overrides: { position: 5 },
};
expect(resolveOverridableEntityProperty(entity, 'title')).toBe(
'Base Title',
);
});
it('should return base value when overrides is undefined', () => {
const entity: TestEntity = {
title: 'Base Title',
position: 0,
icon: null,
overrides: undefined,
};
expect(resolveOverridableEntityProperty(entity, 'title')).toBe(
'Base Title',
);
});
it('should return null when override explicitly sets a nullable property to null', () => {
const entity: TestEntity = {
title: 'Base Title',
position: 0,
icon: 'IconStar',
overrides: { icon: null },
};
expect(resolveOverridableEntityProperty(entity, 'icon')).toBeNull();
});
});
@@ -0,0 +1,136 @@
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
describe('sanitizeOverridableEntityInput', () => {
describe('when shouldOverride is false', () => {
it('should pass through properties unchanged and preserve existing overrides', () => {
const existingOverrides = { title: 'Previous Override' };
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: existingOverrides,
},
updatedEditableProperties: { title: 'New Title' },
shouldOverride: false,
});
expect(result.updatedEditableProperties).toEqual({ title: 'New Title' });
expect(result.overrides).toBe(existingOverrides);
});
});
describe('when shouldOverride is true', () => {
it('should move overridable property to overrides and remove it from editableProperties', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: null,
},
updatedEditableProperties: { title: 'Overridden Title' },
shouldOverride: true,
});
expect(result.overrides).toEqual({ title: 'Overridden Title' });
expect(result.updatedEditableProperties).not.toHaveProperty('title');
});
it('should implicitly restore when new value matches base value by removing the key from overrides', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: { title: 'Old Override', position: 5 },
},
updatedEditableProperties: { title: 'Base Title' },
shouldOverride: true,
});
expect(result.overrides).toEqual({ position: 5 });
expect(result.updatedEditableProperties).not.toHaveProperty('title');
});
it('should return null overrides when removing the last override key', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: { title: 'Old Override' },
},
updatedEditableProperties: { title: 'Base Title' },
shouldOverride: true,
});
expect(result.overrides).toBeNull();
expect(result.updatedEditableProperties).not.toHaveProperty('title');
});
it('should not move non-overridable properties to overrides', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
pageLayoutId: 'layout-1',
overrides: null,
},
updatedEditableProperties: {
title: 'Overridden Title',
pageLayoutId: 'layout-2',
},
shouldOverride: true,
});
expect(result.overrides).toEqual({ title: 'Overridden Title' });
expect(result.updatedEditableProperties).toEqual({
pageLayoutId: 'layout-2',
});
});
it('should preserve existing override keys when updating a different overridable property', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: { title: 'Overridden Title' },
},
updatedEditableProperties: { position: 5 },
shouldOverride: true,
});
expect(result.overrides).toEqual({
title: 'Overridden Title',
position: 5,
});
expect(result.updatedEditableProperties).not.toHaveProperty('position');
});
it('should create new overrides object when no existing overrides', () => {
const result = sanitizeOverridableEntityInput({
metadataName: 'pageLayoutTab',
existingFlatEntity: {
title: 'Base Title',
position: 0,
icon: null,
overrides: null,
},
updatedEditableProperties: { icon: 'IconStar' },
shouldOverride: true,
});
expect(result.overrides).toEqual({ icon: 'IconStar' });
expect(result.updatedEditableProperties).not.toHaveProperty('icon');
});
});
});
@@ -0,0 +1,16 @@
export const isCallerOverridingEntity = ({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
callerApplicationUniversalIdentifier: string;
entityApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): boolean => {
return (
callerApplicationUniversalIdentifier ===
workspaceCustomApplicationUniversalIdentifier &&
entityApplicationUniversalIdentifier !==
workspaceCustomApplicationUniversalIdentifier
);
};
@@ -0,0 +1,11 @@
export const resolveOverridableEntityProperty = <
TEntity extends { overrides?: Partial<TEntity> | null },
K extends string & keyof TEntity,
>(
entity: TEntity,
property: K,
): TEntity[K] => {
const overrideValue = entity.overrides?.[property];
return overrideValue !== undefined ? overrideValue : entity[property];
};
@@ -0,0 +1,88 @@
import isEqual from 'lodash.isequal';
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ALL_OVERRIDABLE_PROPERTIES_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-overridable-properties-by-metadata-name.constant';
type FlatEntityWithOverrides = {
[key: string]: unknown;
overrides: Record<string, unknown> | null;
};
export const sanitizeOverridableEntityInput = <
T extends AllMetadataName,
TProperties extends Record<string, unknown>,
>({
metadataName,
existingFlatEntity,
updatedEditableProperties,
shouldOverride,
}: {
metadataName: T;
existingFlatEntity: FlatEntityWithOverrides;
updatedEditableProperties: TProperties;
shouldOverride: boolean;
}): {
overrides: Record<string, unknown> | null;
updatedEditableProperties: TProperties;
} => {
const existingOverrides = existingFlatEntity.overrides;
if (!shouldOverride) {
return {
overrides: existingOverrides,
updatedEditableProperties,
};
}
const sanitizedEditableProperties = {
...updatedEditableProperties,
} as TProperties;
const overridableProperties = ALL_OVERRIDABLE_PROPERTIES_BY_METADATA_NAME[
metadataName
] as string[];
const overrides = overridableProperties.reduce<Record<
string,
unknown
> | null>((acc, property) => {
const isPropertyUpdated =
sanitizedEditableProperties[property] !== undefined;
if (!isPropertyUpdated) {
return acc;
}
const propertyValue = sanitizedEditableProperties[property];
delete sanitizedEditableProperties[property];
if (isEqual(propertyValue, existingFlatEntity[property])) {
if (
isDefined(acc) &&
Object.prototype.hasOwnProperty.call(acc, property)
) {
const { [property]: _, ...restOverrides } = acc;
return restOverrides;
}
return acc;
}
return {
...acc,
[property]: propertyValue,
};
}, existingOverrides);
if (isDefined(overrides) && Object.keys(overrides).length === 0) {
return {
overrides: null,
updatedEditableProperties: sanitizedEditableProperties,
};
}
return { overrides, updatedEditableProperties: sanitizedEditableProperties };
};