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
@@ -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 {
@@ -12,6 +17,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
@@ -117,4 +123,10 @@ export class ViewDTO {
@Field(() => UUIDScalarType, { nullable: true })
createdByUserWorkspaceId?: string | null;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@HideField()
overrides?: ViewOverrides | null;
}
@@ -14,6 +14,7 @@ import {
} from 'typeorm';
import {
AggregateOperations,
type SerializedRelation,
ViewCalendarLayout,
ViewKey,
ViewOpenRecordIn,
@@ -30,7 +31,24 @@ import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-g
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type ViewOverrides = {
name?: string;
type?: ViewType;
icon?: string;
position?: number;
isCompact?: boolean;
openRecordIn?: ViewOpenRecordIn;
kanbanAggregateOperation?: AggregateOperations | null;
kanbanAggregateOperationFieldMetadataId?: SerializedRelation | null;
anyFieldFilterValue?: string | null;
calendarLayout?: ViewCalendarLayout | null;
calendarFieldMetadataId?: SerializedRelation | null;
visibility?: ViewVisibility;
mainGroupByFieldMetadataId?: SerializedRelation | null;
shouldHideEmptyGroups?: boolean;
};
// We could refactor this type to be dynamic to view type
@Entity({ name: 'view', schema: 'core' })
@@ -49,7 +67,10 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'CHK_VIEW_CALENDAR_INTEGRITY',
`("type" != 'CALENDAR' OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
)
export class ViewEntity extends SyncableEntity implements Required<ViewEntity> {
export class ViewEntity
extends OverridableEntity<ViewOverrides>
implements Required<ViewEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -17,6 +17,7 @@ import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modul
import { fromDeleteViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-delete-view-input-to-flat-view-or-throw.util';
import { fromDestroyViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-destroy-view-input-to-flat-view-or-throw.util';
import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { fromFlatViewFieldGroupToViewFieldGroupDto } from 'src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util';
import { fromFlatViewFieldToViewFieldDto } from 'src/engine/metadata-modules/view-field/utils/from-flat-view-field-to-view-field-dto.util';
import { fromFlatViewFilterGroupToViewFilterGroupDto } from 'src/engine/metadata-modules/view-filter-group/utils/from-flat-view-filter-group-to-view-filter-group-dto.util';
@@ -166,6 +167,10 @@ export class ViewService {
flatViewGroupMaps: existingFlatViewGroupMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
userWorkspaceId,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -239,6 +244,10 @@ export class ViewService {
fromDeleteViewInputToFlatViewOrThrow({
deleteViewInput,
flatViewMaps: existingFlatViewMaps,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -313,14 +322,29 @@ export class ViewService {
flatEntityMaps: existingFlatViewMaps,
});
const shouldDeactivate = isCallerOverridingEntity({
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatView.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const now = new Date().toISOString();
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
view: {
flatEntityToCreate: [],
flatEntityToDelete: [flatViewFromDestroyInput],
flatEntityToUpdate: [],
flatEntityToDelete: shouldDeactivate
? []
: [flatViewFromDestroyInput],
flatEntityToUpdate: shouldDeactivate
? [{ ...existingFlatView, isActive: false, updatedAt: now }]
: [],
},
},
workspaceId,
@@ -337,9 +361,17 @@ export class ViewService {
);
}
if (shouldDeactivate) {
return fromFlatViewToViewDto({
...existingFlatView,
isActive: false,
updatedAt: now,
});
}
return fromFlatViewToViewDto({
...existingFlatView,
deletedAt: new Date().toISOString(),
deletedAt: now,
});
}
@@ -2,10 +2,11 @@ import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-
import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
export const fromFlatViewToViewDto = (flatView: FlatView): ViewDTO => {
const { createdAt, updatedAt, deletedAt, ...rest } = flatView;
const { createdAt, updatedAt, deletedAt, overrides, ...rest } = flatView;
return {
...rest,
...(overrides ?? {}),
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,