View field override (#18572)

## Context
This PR introduces overrides for view fields which will be useful for
page layout FIELDS widgets fields position/groups/visibility override +
restore logic.
This commit is contained in:
Weiko
2026-03-14 12:40:15 +01:00
committed by GitHub
parent 0b0ffcb8fa
commit 48172d60fd
50 changed files with 864 additions and 114 deletions
@@ -1,8 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type ViewFieldGroupOverrides } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
@ObjectType('CoreViewFieldGroup')
@@ -36,4 +37,10 @@ export class ViewFieldGroupDTO {
@Field(() => [ViewFieldDTO])
viewFields?: ViewFieldDTO[];
@Field(() => Boolean, { nullable: false })
isOverridden: boolean;
@HideField()
overrides?: ViewFieldGroupOverrides | null;
}
@@ -14,13 +14,19 @@ import {
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type ViewFieldGroupOverrides = {
name?: string;
position?: number;
isVisible?: boolean;
};
@Entity({ name: 'viewFieldGroup', schema: 'core' })
@Index('IDX_VIEW_FIELD_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FIELD_GROUP_VIEW_ID', ['viewId'])
export class ViewFieldGroupEntity
extends SyncableEntity
extends OverridableEntity<ViewFieldGroupOverrides>
implements Required<ViewFieldGroupEntity>
{
@PrimaryGeneratedColumn('uuid')
@@ -2,6 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common';
import {
Args,
Context,
Float,
Mutation,
Parent,
Query,
@@ -9,6 +10,7 @@ import {
} from '@nestjs/graphql';
import { isArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -16,6 +18,7 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/create-view-field-group.input';
import { DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/delete-view-field-group.input';
import { DestroyViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/destroy-view-field-group.input';
@@ -39,6 +42,29 @@ export class ViewFieldGroupResolver {
private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService,
) {}
@ResolveField(() => String)
name(@Parent() viewFieldGroup: ViewFieldGroupDTO): string {
return resolveOverridableEntityProperty(viewFieldGroup, 'name');
}
@ResolveField(() => Float)
position(@Parent() viewFieldGroup: ViewFieldGroupDTO): number {
return resolveOverridableEntityProperty(viewFieldGroup, 'position');
}
@ResolveField(() => Boolean)
isVisible(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return resolveOverridableEntityProperty(viewFieldGroup, 'isVisible');
}
@ResolveField(() => Boolean)
isOverridden(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return (
isDefined(viewFieldGroup.overrides) &&
Object.keys(viewFieldGroup.overrides).length > 0
);
}
@Query(() => [ViewFieldGroupDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFieldGroups(
@@ -14,8 +14,11 @@ import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-m
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
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 { type UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
@@ -204,11 +207,30 @@ export class FieldsWidgetUpsertService {
}),
);
} else if (this.hasGroupChanged(existingGroup, inputGroup)) {
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingGroup.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedGroupProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewFieldGroup',
existingFlatEntity: existingGroup,
updatedEditableProperties: {
name: inputGroup.name,
position: inputGroup.position,
isVisible: inputGroup.isVisible,
},
shouldOverride,
});
groupsToUpdate.push({
...existingGroup,
name: inputGroup.name,
position: inputGroup.position,
isVisible: inputGroup.isVisible,
...sanitizedGroupProps,
overrides,
updatedAt: now,
});
}
@@ -251,10 +273,22 @@ export class FieldsWidgetUpsertService {
const newViewFieldGroupId = inputGroup.id;
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
// null is a valid override value (meaning "ungrouped"), so use !== undefined
const resolvedViewFieldGroupId =
existingField.overrides?.viewFieldGroupId !== undefined
? existingField.overrides.viewFieldGroupId
: existingField.viewFieldGroupId;
const hasChanged =
existingField.isVisible !== inputField.isVisible ||
existingField.position !== inputField.position ||
existingField.viewFieldGroupId !== newViewFieldGroupId;
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
resolvedViewFieldGroupId !== newViewFieldGroupId;
if (!hasChanged) {
return [];
@@ -271,18 +305,72 @@ export class FieldsWidgetUpsertService {
},
});
return [
{
...existingField,
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: newViewFieldGroupId,
viewFieldGroupUniversalIdentifier,
updatedAt: now,
},
];
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: newViewFieldGroupId,
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (sanitizedFieldProps.viewFieldGroupId !== undefined) {
const resolved = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
viewFieldGroupId: updatedField.viewFieldGroupId,
},
flatEntityMaps: {
flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps,
},
});
updatedField.viewFieldGroupUniversalIdentifier =
resolved.viewFieldGroupUniversalIdentifier;
}
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById:
optimisticFlatViewFieldGroupMaps.universalIdentifierById,
});
} else {
updatedField.universalOverrides = null;
}
return [updatedField];
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -295,7 +383,10 @@ export class FieldsWidgetUpsertService {
viewField: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldsToUpdate,
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
},
},
workspaceId,
@@ -338,27 +429,80 @@ export class FieldsWidgetUpsertService {
return [];
}
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
const resolvedViewFieldGroupId =
existingField.overrides?.viewFieldGroupId !== undefined
? existingField.overrides.viewFieldGroupId
: existingField.viewFieldGroupId;
const hasChanged =
existingField.isVisible !== inputField.isVisible ||
existingField.position !== inputField.position ||
existingField.viewFieldGroupId !== null;
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
resolvedViewFieldGroupId !== null;
if (!hasChanged) {
return [];
}
return [
{
...existingField,
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
},
];
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: null as string | null,
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (sanitizedFieldProps.viewFieldGroupId !== undefined) {
updatedField.viewFieldGroupUniversalIdentifier = null;
}
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById: {},
});
} else {
updatedField.universalOverrides = null;
}
return [updatedField];
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now: new Date().toISOString(),
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -371,7 +515,10 @@ export class FieldsWidgetUpsertService {
viewField: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldsToUpdate,
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
},
},
workspaceId,
@@ -388,6 +535,111 @@ export class FieldsWidgetUpsertService {
}
}
private buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds,
now,
}: {
existingViewFields: FlatViewField[];
groupsToDelete: FlatViewFieldGroup[];
alreadyUpdatedFieldIds: Set<string>;
now: string;
}): FlatViewField[] {
if (groupsToDelete.length === 0) {
return [];
}
const deletedGroupIds = new Set(groupsToDelete.map((group) => group.id));
return existingViewFields
.filter((field) => {
if (alreadyUpdatedFieldIds.has(field.id)) {
return false;
}
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
const hasStaleBase =
overriddenGroupId === undefined &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
const hasStaleBaseHiddenByNullOverride =
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return (
hasStaleOverride || hasStaleBase || hasStaleBaseHiddenByNullOverride
);
})
.map((field) => {
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
if (hasStaleOverride) {
const { viewFieldGroupId: _, ...remainingOverrides } =
field.overrides!;
const cleanedOverrides =
Object.keys(remainingOverrides).length > 0
? (remainingOverrides as typeof field.overrides)
: null;
const baseGroupIsAlsoStale =
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return {
...field,
...(baseGroupIsAlsoStale
? {
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
}
: {}),
overrides: cleanedOverrides,
universalOverrides: isDefined(cleanedOverrides)
? fromViewFieldOverridesToUniversalOverrides({
overrides: cleanedOverrides,
viewFieldGroupUniversalIdentifierById: {},
})
: null,
updatedAt: now,
};
}
if (
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId)
) {
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
}
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
});
}
private buildGroupToCreate({
inputGroup,
viewId,
@@ -423,6 +675,7 @@ export class FieldsWidgetUpsertService {
isVisible: inputGroup.isVisible,
viewId,
viewUniversalIdentifier,
overrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -435,10 +688,20 @@ export class FieldsWidgetUpsertService {
existing: FlatViewFieldGroup,
input: UpsertFieldsWidgetGroupInput,
): boolean {
const resolvedName = isDefined(existing.overrides?.name)
? existing.overrides.name
: existing.name;
const resolvedPosition = isDefined(existing.overrides?.position)
? existing.overrides.position
: existing.position;
const resolvedIsVisible = isDefined(existing.overrides?.isVisible)
? existing.overrides.isVisible
: existing.isVisible;
return (
existing.name !== input.name ||
existing.position !== input.position ||
existing.isVisible !== input.isVisible
resolvedName !== input.name ||
resolvedPosition !== input.position ||
resolvedIsVisible !== input.isVisible
);
}
}
@@ -157,6 +157,10 @@ export class ViewFieldGroupService {
fromUpdateViewFieldGroupInputToFlatViewFieldGroupToUpdateOrThrow({
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
updateViewFieldGroupInput,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
@@ -8,6 +10,8 @@ export const fromFlatViewFieldGroupToViewFieldGroupDto = (
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,