feat(server): convert view to overridable entity (#21436)

## Context

Every entity created as a side effect of object creation must support
the overridable pattern (`isActive` + `overrides` + override routing)
before we can re-own side effects to their true application. Starting
with View.

viewField, viewFieldGroup, pageLayoutTab and pageLayoutWidget already
extend `OverridableEntity`. This PR brings `view` to the same pattern.

## What this does

- `ViewEntity` now extends `OverridableEntity<ViewOverrides>` (adds
`isActive` boolean + `overrides` jsonb). All editable view properties
are overridable; the 3 fieldMetadata foreign keys are converted to/from
universal identifiers like viewField's `viewFieldGroupId`.
- **Update**: mutations on a view not owned by the caller (e.g. standard
views like "All Companies") are written into `overrides` instead of
mutating the row. Reads merge overrides in
the DTO.
- **Delete/destroy**: views not owned by the caller are deactivated
(`isActive = false`) instead of deleted.
~~- **INDEX invariant**: `key = INDEX` views can only be created via
object-creation side effect. The API now rejects creating, deleting or
destroying INDEX views (object-deletion cascade is unaffected). This was
not really needed for this migration but was flagged during
implementation.~~
- **Front**: views with `isActive = false` are filtered out of the views
selector.
- Fast instance command adds the two columns
(`2-12-instance-command-fast-...-view-overridable-entity.ts`).

## Notes

- Custom (caller-owned) views behave exactly as before: direct updates,
soft delete.
- View-group side effects (kanban groups) are computed on the
override-merged view so overridden `mainGroupByFieldMetadataId` works.
This commit is contained in:
Weiko
2026-06-11 18:00:52 +02:00
committed by GitHub
parent 947a4d5253
commit cfb9772179
35 changed files with 497 additions and 52 deletions
@@ -12,7 +12,9 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = {
settings: 'settings',
},
objectMetadata: {},
view: {},
view: {
overrides: 'overrides',
},
viewField: {
overrides: 'overrides',
},
@@ -64,6 +64,8 @@ type Assertions = [
| 'anyFieldFilterValue'
| 'visibility'
| 'createdByUserWorkspaceId'
| 'isActive'
| 'universalOverrides'
>
>,
];
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
@@ -10,6 +11,7 @@ import {
FlatCreateViewAction,
UniversalCreateViewAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type';
import { fromUniversalOverridesToViewOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view/services/utils/from-universal-overrides-to-view-overrides.util';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
@@ -41,6 +43,13 @@ export class CreateViewActionHandlerService extends WorkspaceMigrationRunnerActi
universalForeignKeyValues: action.flatEntity,
});
const overrides = isDefined(action.flatEntity.universalOverrides)
? fromUniversalOverridesToViewOverrides({
universalOverrides: action.flatEntity.universalOverrides,
flatFieldMetadataMaps: allFlatEntityMaps.flatFieldMetadataMaps,
})
: null;
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
metadataName: 'view',
@@ -54,6 +63,7 @@ export class CreateViewActionHandlerService extends WorkspaceMigrationRunnerActi
kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId,
objectMetadataId,
overrides,
id: action.id ?? v4(),
applicationId: flatApplication.id,
workspaceId,
@@ -9,6 +9,7 @@ import {
FlatUpdateViewAction,
UniversalUpdateViewAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type';
import { fromUniversalOverridesToViewOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view/services/utils/from-universal-overrides-to-view-overrides.util';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
@@ -33,11 +34,25 @@ export class UpdateViewActionHandlerService extends WorkspaceMigrationRunnerActi
universalIdentifier: action.universalIdentifier,
});
const update = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'view',
universalUpdate: action.update,
allFlatEntityMaps,
});
const { universalOverrides, ...updateWithResolvedForeignKeys } =
resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'view',
universalUpdate: action.update,
allFlatEntityMaps,
});
const update =
universalOverrides === undefined
? updateWithResolvedForeignKeys
: universalOverrides === null
? { ...updateWithResolvedForeignKeys, overrides: null }
: {
...updateWithResolvedForeignKeys,
overrides: fromUniversalOverridesToViewOverrides({
universalOverrides,
flatFieldMetadataMaps: allFlatEntityMaps.flatFieldMetadataMaps,
}),
};
return {
type: 'update',
@@ -0,0 +1,66 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
type UniversalViewOverrides =
FormatRecordSerializedRelationProperties<ViewOverrides>;
const VIEW_OVERRIDES_UNIVERSAL_FIELD_METADATA_PROPERTIES = [
'kanbanAggregateOperationFieldMetadataUniversalIdentifier',
'calendarFieldMetadataUniversalIdentifier',
'mainGroupByFieldMetadataUniversalIdentifier',
] as const;
type ViewOverridesUniversalFieldMetadataProperty =
(typeof VIEW_OVERRIDES_UNIVERSAL_FIELD_METADATA_PROPERTIES)[number];
const toForeignKeyProperty = (
universalProperty: ViewOverridesUniversalFieldMetadataProperty,
) =>
universalProperty.replace(
/UniversalIdentifier$/,
'Id',
) as keyof ViewOverrides;
export const fromUniversalOverridesToViewOverrides = ({
universalOverrides,
flatFieldMetadataMaps,
}: {
universalOverrides: UniversalViewOverrides;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): ViewOverrides => {
const {
kanbanAggregateOperationFieldMetadataUniversalIdentifier: _kanban,
calendarFieldMetadataUniversalIdentifier: _calendar,
mainGroupByFieldMetadataUniversalIdentifier: _mainGroupBy,
...scalarOverrides
} = universalOverrides;
return VIEW_OVERRIDES_UNIVERSAL_FIELD_METADATA_PROPERTIES.reduce<ViewOverrides>(
(acc, universalProperty) => {
const universalIdentifier = universalOverrides[universalProperty];
if (universalIdentifier === undefined) {
return acc;
}
const foreignKeyProperty = toForeignKeyProperty(universalProperty);
if (universalIdentifier === null) {
return { ...acc, [foreignKeyProperty]: null };
}
const flatFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier,
});
return { ...acc, [foreignKeyProperty]: flatFieldMetadata?.id ?? null };
},
scalarOverrides,
);
};