CommandMenuItem overridable entity (#21486)

## Context
Second PR of the overridable-entities track (after #21436 for views):
command menu items become overridable so that edits on non-owned items
are stored as overrides instead of mutating the row, and
deletion/deactivation becomes reversible.

 ## What this does

- `CommandMenuItemEntity` now extends
`OverridableEntity<CommandMenuItemOverrides>` (adds `isActive` +
`overrides`). All editable properties are overridable for now (to
discuss).
- **Update**: mutations on a command item not owned by the caller
(standard items) are written into `overrides`; reads merge them in the
DTO. The command palette edit mode (pin,
reorder, shortLabel) now preserves standard values, "Reset label to
default" gains true post-save semantics.
- **Delete**: protected items are deactivated (`isActive = false`)
instead of deleted; custom items still hard-delete.
- **Object deactivate/enable toggle**: now flips `isActive` on the
command item (merged into the main migration call) instead of
delete/recreate; a create-if-missing fallback covers legacy deactivated
objects.
- **Front**: inactive command items are filtered out of the palette
selector.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21486?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2026-06-13 14:21:27 +02:00
committed by GitHub
parent 036d9a2bcf
commit f63f053444
34 changed files with 481 additions and 78 deletions
@@ -100,6 +100,9 @@ export const buildNavigationFlatCommandMenuItem = ({
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -1,5 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { fromCommandMenuItemOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-overrides-to-universal-overrides.util';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import {
FlatEntityMapsException,
@@ -74,6 +75,19 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
}
}
const universalOverrides = isDefined(commandMenuItemEntity.overrides)
? fromCommandMenuItemOverridesToUniversalOverrides({
overrides: commandMenuItemEntity.overrides,
objectMetadataUniversalIdentifierById: Object.fromEntries(
objectMetadataIdToUniversalIdentifierMap.entries(),
),
pageLayoutUniversalIdentifierById: Object.fromEntries(
pageLayoutIdToUniversalIdentifierMap.entries(),
),
shouldThrowOnMissingIdentifier: false,
})
: null;
return {
id: commandMenuItemEntity.id,
workflowVersionId: commandMenuItemEntity.workflowVersionId,
@@ -101,5 +115,8 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
frontComponentUniversalIdentifier,
pageLayoutId: commandMenuItemEntity.pageLayoutId,
pageLayoutUniversalIdentifier,
isActive: commandMenuItemEntity.isActive,
overrides: commandMenuItemEntity.overrides,
universalOverrides,
};
};
@@ -0,0 +1,78 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemOverrides } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
type UniversalCommandMenuItemOverrides =
FormatRecordSerializedRelationProperties<CommandMenuItemOverrides>;
const COMMAND_MENU_ITEM_OVERRIDES_FOREIGN_KEYS = [
{
foreignKey: 'availabilityObjectMetadataId',
universalProperty: 'availabilityObjectMetadataUniversalIdentifier',
mapName: 'objectMetadata',
},
{
foreignKey: 'pageLayoutId',
universalProperty: 'pageLayoutUniversalIdentifier',
mapName: 'pageLayout',
},
] as const;
export const fromCommandMenuItemOverridesToUniversalOverrides = ({
overrides,
objectMetadataUniversalIdentifierById,
pageLayoutUniversalIdentifierById,
shouldThrowOnMissingIdentifier = true,
}: {
overrides: CommandMenuItemOverrides;
objectMetadataUniversalIdentifierById: Partial<Record<string, string>>;
pageLayoutUniversalIdentifierById: Partial<Record<string, string>>;
shouldThrowOnMissingIdentifier?: boolean;
}): UniversalCommandMenuItemOverrides => {
const {
availabilityObjectMetadataId: _availabilityObjectMetadataId,
pageLayoutId: _pageLayoutId,
...scalarOverrides
} = overrides;
const universalIdentifierByIdByMapName = {
objectMetadata: objectMetadataUniversalIdentifierById,
pageLayout: pageLayoutUniversalIdentifierById,
};
return COMMAND_MENU_ITEM_OVERRIDES_FOREIGN_KEYS.reduce<UniversalCommandMenuItemOverrides>(
(acc, { foreignKey, universalProperty, mapName }) => {
const foreignKeyValue = overrides[foreignKey];
if (foreignKeyValue === undefined) {
return acc;
}
if (foreignKeyValue === null) {
return { ...acc, [universalProperty]: null };
}
const universalIdentifier =
universalIdentifierByIdByMapName[mapName][foreignKeyValue];
if (!isDefined(universalIdentifier)) {
if (shouldThrowOnMissingIdentifier) {
throw new FlatEntityMapsException(
`${mapName} universal identifier not found for id: ${foreignKeyValue}`,
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
);
}
return { ...acc, [universalProperty]: null };
}
return { ...acc, [universalProperty]: universalIdentifier };
},
scalarOverrides,
);
};
@@ -76,6 +76,9 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -3,26 +3,37 @@ import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-comma
export const fromFlatCommandMenuItemToCommandMenuItemDto = (
flatCommandMenuItem: FlatCommandMenuItem,
): CommandMenuItemDTO => ({
id: flatCommandMenuItem.id,
workflowVersionId: flatCommandMenuItem.workflowVersionId ?? undefined,
frontComponentId: flatCommandMenuItem.frontComponentId ?? undefined,
engineComponentKey: flatCommandMenuItem.engineComponentKey,
label: flatCommandMenuItem.label,
icon: flatCommandMenuItem.icon ?? undefined,
shortLabel: flatCommandMenuItem.shortLabel ?? undefined,
position: flatCommandMenuItem.position,
isPinned: flatCommandMenuItem.isPinned,
payload: flatCommandMenuItem.payload ?? undefined,
hotKeys: flatCommandMenuItem.hotKeys ?? undefined,
availabilityType: flatCommandMenuItem.availabilityType,
conditionalAvailabilityExpression:
flatCommandMenuItem.conditionalAvailabilityExpression ?? undefined,
availabilityObjectMetadataId:
flatCommandMenuItem.availabilityObjectMetadataId ?? undefined,
pageLayoutId: flatCommandMenuItem.pageLayoutId ?? undefined,
workspaceId: flatCommandMenuItem.workspaceId,
applicationId: flatCommandMenuItem.applicationId ?? undefined,
createdAt: new Date(flatCommandMenuItem.createdAt),
updatedAt: new Date(flatCommandMenuItem.updatedAt),
});
): CommandMenuItemDTO => {
const effectiveFlatCommandMenuItem = {
...flatCommandMenuItem,
...(flatCommandMenuItem.overrides ?? {}),
};
return {
id: effectiveFlatCommandMenuItem.id,
workflowVersionId:
effectiveFlatCommandMenuItem.workflowVersionId ?? undefined,
frontComponentId:
effectiveFlatCommandMenuItem.frontComponentId ?? undefined,
engineComponentKey: effectiveFlatCommandMenuItem.engineComponentKey,
label: effectiveFlatCommandMenuItem.label,
icon: effectiveFlatCommandMenuItem.icon ?? undefined,
shortLabel: effectiveFlatCommandMenuItem.shortLabel ?? undefined,
position: effectiveFlatCommandMenuItem.position,
isPinned: effectiveFlatCommandMenuItem.isPinned,
payload: effectiveFlatCommandMenuItem.payload ?? undefined,
hotKeys: effectiveFlatCommandMenuItem.hotKeys ?? undefined,
availabilityType: effectiveFlatCommandMenuItem.availabilityType,
conditionalAvailabilityExpression:
effectiveFlatCommandMenuItem.conditionalAvailabilityExpression ??
undefined,
availabilityObjectMetadataId:
effectiveFlatCommandMenuItem.availabilityObjectMetadataId ?? undefined,
pageLayoutId: effectiveFlatCommandMenuItem.pageLayoutId ?? undefined,
workspaceId: effectiveFlatCommandMenuItem.workspaceId,
applicationId: effectiveFlatCommandMenuItem.applicationId ?? undefined,
isActive: effectiveFlatCommandMenuItem.isActive,
createdAt: new Date(effectiveFlatCommandMenuItem.createdAt),
updatedAt: new Date(effectiveFlatCommandMenuItem.updatedAt),
};
};
@@ -5,12 +5,16 @@ import {
CommandMenuItemExceptionCode,
} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input';
import { type CommandMenuItemOverrides } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { fromCommandMenuItemOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-overrides-to-universal-overrides.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 const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow =
@@ -19,9 +23,13 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
updateCommandMenuItemInput,
flatObjectMetadataMaps,
flatPageLayoutMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
flatCommandMenuItemMaps: FlatCommandMenuItemMaps;
updateCommandMenuItemInput: UpdateCommandMenuItemInput;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatPageLayoutMaps'
@@ -40,22 +48,41 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
const { id: _id, ...updates } = updateCommandMenuItemInput;
const flatCommandMenuItemToUpdate = {
...mergeUpdateInExistingRecord({
existing: existingFlatCommandMenuItem,
properties: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES],
update: updates,
}),
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatCommandMenuItem.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'commandMenuItem',
existingFlatEntity: existingFlatCommandMenuItem,
updatedEditableProperties: updates,
shouldOverride,
});
const mergedRecord = mergeUpdateInExistingRecord({
existing: existingFlatCommandMenuItem,
properties: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES],
update: updatedEditableProperties,
});
const flatCommandMenuItemToUpdate: FlatCommandMenuItem = {
...mergedRecord,
// sanitizeOverridableEntityInput returns a loosely-typed Record overrides
overrides: overrides as FlatCommandMenuItem['overrides'],
updatedAt: new Date().toISOString(),
};
if (updates.availabilityObjectMetadataId !== undefined) {
if (updatedEditableProperties.availabilityObjectMetadataId !== undefined) {
const { availabilityObjectMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'commandMenuItem',
foreignKeyValues: {
availabilityObjectMetadataId:
flatCommandMenuItemToUpdate.availabilityObjectMetadataId,
mergedRecord.availabilityObjectMetadataId,
},
flatEntityMaps: { flatObjectMetadataMaps },
});
@@ -64,12 +91,12 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
availabilityObjectMetadataUniversalIdentifier;
}
if (updates.pageLayoutId !== undefined) {
if (updatedEditableProperties.pageLayoutId !== undefined) {
const { pageLayoutUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'commandMenuItem',
foreignKeyValues: {
pageLayoutId: flatCommandMenuItemToUpdate.pageLayoutId,
pageLayoutId: mergedRecord.pageLayoutId,
},
flatEntityMaps: { flatPageLayoutMaps },
});
@@ -78,5 +105,18 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
pageLayoutUniversalIdentifier;
}
if (isDefined(overrides)) {
flatCommandMenuItemToUpdate.universalOverrides =
fromCommandMenuItemOverridesToUniversalOverrides({
overrides: overrides as CommandMenuItemOverrides,
objectMetadataUniversalIdentifierById:
flatObjectMetadataMaps.universalIdentifierById,
pageLayoutUniversalIdentifierById:
flatPageLayoutMaps.universalIdentifierById,
});
} else {
flatCommandMenuItemToUpdate.universalOverrides = null;
}
return flatCommandMenuItemToUpdate;
};