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:
+2
@@ -86,6 +86,8 @@ export const fromCreateViewInputToFlatViewToCreate = ({
|
||||
universalIdentifier: createViewInput.universalIdentifier ?? v4(),
|
||||
visibility: createViewInput.visibility ?? ViewVisibility.WORKSPACE,
|
||||
createdByUserWorkspaceId: createdByUserWorkspaceId ?? null,
|
||||
isActive: true,
|
||||
universalOverrides: null,
|
||||
viewFieldUniversalIdentifiers: [],
|
||||
viewFilterUniversalIdentifiers: [],
|
||||
viewGroupUniversalIdentifiers: [],
|
||||
|
||||
+23
-1
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
|
||||
import { type DeleteViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/delete-view.input';
|
||||
import {
|
||||
ViewException,
|
||||
@@ -16,9 +17,13 @@ import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-m
|
||||
export const fromDeleteViewInputToFlatViewOrThrow = ({
|
||||
deleteViewInput: rawDeleteViewInput,
|
||||
flatViewMaps,
|
||||
callerApplicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier,
|
||||
}: {
|
||||
deleteViewInput: DeleteViewInput;
|
||||
flatViewMaps: FlatViewMaps;
|
||||
callerApplicationUniversalIdentifier: string;
|
||||
workspaceCustomApplicationUniversalIdentifier: string;
|
||||
}): UniversalFlatView => {
|
||||
const { id: viewId } = extractAndSanitizeObjectStringFields(
|
||||
rawDeleteViewInput,
|
||||
@@ -37,8 +42,25 @@ export const fromDeleteViewInputToFlatViewOrThrow = ({
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const shouldDeactivate = isCallerOverridingEntity({
|
||||
callerApplicationUniversalIdentifier,
|
||||
entityApplicationUniversalIdentifier:
|
||||
existingFlatViewToDelete.applicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (shouldDeactivate) {
|
||||
return {
|
||||
...existingFlatViewToDelete,
|
||||
isActive: false,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...existingFlatViewToDelete,
|
||||
deletedAt: new Date().toISOString(),
|
||||
deletedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
+57
-13
@@ -12,8 +12,12 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
|
||||
import { type FlatViewGroupMaps } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group-maps.type';
|
||||
import { FLAT_VIEW_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/flat-view-editable-properties.constant';
|
||||
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
|
||||
import { fromViewOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view/utils/from-view-overrides-to-universal-overrides.util';
|
||||
import { handleFlatViewUpdateSideEffect } from 'src/engine/metadata-modules/flat-view/utils/handle-flat-view-update-side-effect.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 { type UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
|
||||
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import {
|
||||
ViewException,
|
||||
ViewExceptionCode,
|
||||
@@ -28,12 +32,16 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
flatViewGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
userWorkspaceId,
|
||||
callerApplicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier,
|
||||
}: {
|
||||
updateViewInput: UpdateViewInput;
|
||||
flatViewMaps: FlatViewMaps;
|
||||
flatViewGroupMaps: FlatViewGroupMaps;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
userWorkspaceId?: string;
|
||||
callerApplicationUniversalIdentifier: string;
|
||||
workspaceCustomApplicationUniversalIdentifier: string;
|
||||
}): {
|
||||
flatViewToUpdate: UniversalFlatView;
|
||||
flatViewGroupsToDelete: UniversalFlatViewGroup[];
|
||||
@@ -57,19 +65,39 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
);
|
||||
}
|
||||
|
||||
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
|
||||
const editableProperties = extractAndSanitizeObjectStringFields(
|
||||
rawUpdateViewInput,
|
||||
FLAT_VIEW_EDITABLE_PROPERTIES,
|
||||
);
|
||||
|
||||
const flatViewToUpdate = mergeUpdateInExistingRecord({
|
||||
existing: existingFlatViewToUpdate,
|
||||
properties: FLAT_VIEW_EDITABLE_PROPERTIES,
|
||||
update: updatedEditableFieldProperties,
|
||||
const shouldOverride = isCallerOverridingEntity({
|
||||
callerApplicationUniversalIdentifier,
|
||||
entityApplicationUniversalIdentifier:
|
||||
existingFlatViewToUpdate.applicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
const { overrides, updatedEditableProperties } =
|
||||
sanitizeOverridableEntityInput({
|
||||
metadataName: 'view',
|
||||
existingFlatEntity: existingFlatViewToUpdate,
|
||||
updatedEditableProperties: editableProperties,
|
||||
shouldOverride,
|
||||
});
|
||||
|
||||
const mergedRecord = mergeUpdateInExistingRecord({
|
||||
existing: existingFlatViewToUpdate,
|
||||
properties: [...FLAT_VIEW_EDITABLE_PROPERTIES],
|
||||
update: updatedEditableProperties,
|
||||
});
|
||||
|
||||
const flatViewToUpdate = {
|
||||
...mergedRecord,
|
||||
overrides,
|
||||
} as UniversalFlatView;
|
||||
|
||||
if (
|
||||
updatedEditableFieldProperties.kanbanAggregateOperationFieldMetadataId !==
|
||||
updatedEditableProperties.kanbanAggregateOperationFieldMetadataId !==
|
||||
undefined
|
||||
) {
|
||||
const { kanbanAggregateOperationFieldMetadataUniversalIdentifier } =
|
||||
@@ -77,7 +105,7 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
metadataName: 'view',
|
||||
foreignKeyValues: {
|
||||
kanbanAggregateOperationFieldMetadataId:
|
||||
flatViewToUpdate.kanbanAggregateOperationFieldMetadataId,
|
||||
mergedRecord.kanbanAggregateOperationFieldMetadataId,
|
||||
},
|
||||
flatEntityMaps: { flatFieldMetadataMaps },
|
||||
});
|
||||
@@ -86,12 +114,12 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
kanbanAggregateOperationFieldMetadataUniversalIdentifier;
|
||||
}
|
||||
|
||||
if (updatedEditableFieldProperties.calendarFieldMetadataId !== undefined) {
|
||||
if (updatedEditableProperties.calendarFieldMetadataId !== undefined) {
|
||||
const { calendarFieldMetadataUniversalIdentifier } =
|
||||
resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'view',
|
||||
foreignKeyValues: {
|
||||
calendarFieldMetadataId: flatViewToUpdate.calendarFieldMetadataId,
|
||||
calendarFieldMetadataId: mergedRecord.calendarFieldMetadataId,
|
||||
},
|
||||
flatEntityMaps: { flatFieldMetadataMaps },
|
||||
});
|
||||
@@ -100,13 +128,12 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
calendarFieldMetadataUniversalIdentifier;
|
||||
}
|
||||
|
||||
if (updatedEditableFieldProperties.mainGroupByFieldMetadataId !== undefined) {
|
||||
if (updatedEditableProperties.mainGroupByFieldMetadataId !== undefined) {
|
||||
const { mainGroupByFieldMetadataUniversalIdentifier } =
|
||||
resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'view',
|
||||
foreignKeyValues: {
|
||||
mainGroupByFieldMetadataId:
|
||||
flatViewToUpdate.mainGroupByFieldMetadataId,
|
||||
mainGroupByFieldMetadataId: mergedRecord.mainGroupByFieldMetadataId,
|
||||
},
|
||||
flatEntityMaps: { flatFieldMetadataMaps },
|
||||
});
|
||||
@@ -115,6 +142,18 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
mainGroupByFieldMetadataUniversalIdentifier;
|
||||
}
|
||||
|
||||
if (isDefined(overrides)) {
|
||||
flatViewToUpdate.universalOverrides = fromViewOverridesToUniversalOverrides(
|
||||
{
|
||||
overrides: overrides as ViewOverrides,
|
||||
fieldMetadataUniversalIdentifierById:
|
||||
flatFieldMetadataMaps.universalIdentifierById,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
flatViewToUpdate.universalOverrides = null;
|
||||
}
|
||||
|
||||
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
|
||||
// This prevents the view from disappearing for the user making the change
|
||||
if (
|
||||
@@ -127,10 +166,15 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
|
||||
flatViewToUpdate.createdByUserWorkspaceId = userWorkspaceId;
|
||||
}
|
||||
|
||||
const effectiveFlatViewToUpdate = {
|
||||
...mergedRecord,
|
||||
...((overrides as ViewOverrides | null) ?? {}),
|
||||
};
|
||||
|
||||
const { flatViewGroupsToDelete, flatViewGroupsToCreate } =
|
||||
handleFlatViewUpdateSideEffect({
|
||||
fromFlatView: existingFlatViewToUpdate,
|
||||
toFlatView: flatViewToUpdate,
|
||||
toFlatView: effectiveFlatViewToUpdate,
|
||||
flatViewGroupMaps: flatViewGroupMaps,
|
||||
flatFieldMetadataMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
+12
@@ -6,6 +6,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util';
|
||||
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
|
||||
import { fromViewOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view/utils/from-view-overrides-to-universal-overrides.util';
|
||||
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
|
||||
|
||||
export const fromViewEntityToFlatView = ({
|
||||
@@ -88,12 +89,23 @@ export const fromViewEntityToFlatView = ({
|
||||
}
|
||||
}
|
||||
|
||||
const universalOverrides = isDefined(viewEntity.overrides)
|
||||
? fromViewOverridesToUniversalOverrides({
|
||||
overrides: viewEntity.overrides,
|
||||
fieldMetadataUniversalIdentifierById: Object.fromEntries(
|
||||
fieldMetadataIdToUniversalIdentifierMap.entries(),
|
||||
),
|
||||
shouldThrowOnMissingIdentifier: false,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...viewEntityWithoutRelations,
|
||||
createdAt: viewEntity.createdAt.toISOString(),
|
||||
updatedAt: viewEntity.updatedAt.toISOString(),
|
||||
deletedAt: viewEntity.deletedAt?.toISOString() ?? null,
|
||||
universalIdentifier: viewEntityWithoutRelations.universalIdentifier,
|
||||
universalOverrides,
|
||||
viewFieldIds: viewEntity.viewFields.map(({ id }) => id),
|
||||
viewFieldGroupIds: viewEntity.viewFieldGroups?.map(({ id }) => id) ?? [],
|
||||
viewFilterIds: viewEntity.viewFilters.map(({ id }) => id),
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
FlatEntityMapsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
|
||||
import { type ViewOverrides } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
|
||||
type UniversalViewOverrides =
|
||||
FormatRecordSerializedRelationProperties<ViewOverrides>;
|
||||
|
||||
const VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS = [
|
||||
'kanbanAggregateOperationFieldMetadataId',
|
||||
'calendarFieldMetadataId',
|
||||
'mainGroupByFieldMetadataId',
|
||||
] as const;
|
||||
|
||||
type ViewOverridesFieldMetadataForeignKey =
|
||||
(typeof VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS)[number];
|
||||
|
||||
const toUniversalIdentifierProperty = (
|
||||
foreignKey: ViewOverridesFieldMetadataForeignKey,
|
||||
) =>
|
||||
foreignKey.replace(
|
||||
/Id$/,
|
||||
'UniversalIdentifier',
|
||||
) as keyof UniversalViewOverrides;
|
||||
|
||||
export const fromViewOverridesToUniversalOverrides = ({
|
||||
overrides,
|
||||
fieldMetadataUniversalIdentifierById,
|
||||
shouldThrowOnMissingIdentifier = true,
|
||||
}: {
|
||||
overrides: ViewOverrides;
|
||||
fieldMetadataUniversalIdentifierById: Partial<Record<string, string>>;
|
||||
shouldThrowOnMissingIdentifier?: boolean;
|
||||
}): UniversalViewOverrides => {
|
||||
const {
|
||||
kanbanAggregateOperationFieldMetadataId: _kanban,
|
||||
calendarFieldMetadataId: _calendar,
|
||||
mainGroupByFieldMetadataId: _mainGroupBy,
|
||||
...scalarOverrides
|
||||
} = overrides;
|
||||
|
||||
return VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS.reduce<UniversalViewOverrides>(
|
||||
(acc, foreignKey) => {
|
||||
const foreignKeyValue = overrides[foreignKey];
|
||||
|
||||
if (foreignKeyValue === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const universalIdentifierProperty =
|
||||
toUniversalIdentifierProperty(foreignKey);
|
||||
|
||||
if (foreignKeyValue === null) {
|
||||
return { ...acc, [universalIdentifierProperty]: null };
|
||||
}
|
||||
|
||||
const universalIdentifier =
|
||||
fieldMetadataUniversalIdentifierById[foreignKeyValue];
|
||||
|
||||
if (!isDefined(universalIdentifier)) {
|
||||
if (shouldThrowOnMissingIdentifier) {
|
||||
throw new FlatEntityMapsException(
|
||||
`FieldMetadata universal identifier not found for id: ${foreignKeyValue}`,
|
||||
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...acc, [universalIdentifierProperty]: null };
|
||||
}
|
||||
|
||||
return { ...acc, [universalIdentifierProperty]: universalIdentifier };
|
||||
},
|
||||
scalarOverrides,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user