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
@@ -345,6 +345,7 @@ type CommandMenuItem {
pageLayoutId: UUID
universalIdentifier: UUID
applicationId: UUID
isActive: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -299,6 +299,7 @@ export interface CommandMenuItem {
pageLayoutId?: Scalars['UUID']
universalIdentifier?: Scalars['UUID']
applicationId?: Scalars['UUID']
isActive: Scalars['Boolean']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'CommandMenuItem'
@@ -3205,6 +3206,7 @@ export interface CommandMenuItemGenqlSelection{
pageLayoutId?: boolean | number
universalIdentifier?: boolean | number
applicationId?: boolean | number
isActive?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
@@ -769,6 +769,9 @@ export default {
"applicationId": [
3
],
"isActive": [
6
],
"createdAt": [
4
],
File diff suppressed because one or more lines are too long
@@ -30,5 +30,6 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
availabilityType
availabilityObjectMetadataId
pageLayoutId
isActive
}
`;
@@ -8,6 +8,7 @@ export const createMockCommandMenuItems =
(): CommandMenuItemFieldsFragment[] => [
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-add-to-favorites',
workflowVersionId: null,
frontComponentId: null,
@@ -26,6 +27,7 @@ export const createMockCommandMenuItems =
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-export',
workflowVersionId: null,
frontComponentId: null,
@@ -44,6 +46,7 @@ export const createMockCommandMenuItems =
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-delete',
workflowVersionId: null,
frontComponentId: null,
@@ -62,6 +65,7 @@ export const createMockCommandMenuItems =
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-people',
workflowVersionId: null,
frontComponentId: null,
@@ -22,13 +22,15 @@ export const commandMenuItemsSelector = createAtomSelector<
]),
);
return commandMenuItems.map((item) => ({
...item,
frontComponent: isDefined(item.frontComponentId)
? (frontComponentsById.get(item.frontComponentId) ??
item.frontComponent ??
null)
: null,
})) as CommandMenuItemFieldsFragment[];
return commandMenuItems
.filter((item) => item.isActive)
.map((item) => ({
...item,
frontComponent: isDefined(item.frontComponentId)
? (frontComponentsById.get(item.frontComponentId) ??
item.frontComponent ??
null)
: null,
})) as CommandMenuItemFieldsFragment[];
},
});
@@ -34,6 +34,7 @@ const buildMockItem = (
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
isActive: true,
...overrides,
});
@@ -7,6 +7,7 @@ import {
export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-people',
workflowVersionId: null,
frontComponentId: null,
@@ -24,6 +25,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-opportunities',
workflowVersionId: null,
frontComponentId: null,
@@ -42,6 +44,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-settings',
workflowVersionId: null,
frontComponentId: null,
@@ -59,6 +62,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-tasks',
workflowVersionId: null,
frontComponentId: null,
@@ -76,6 +80,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-go-to-notes',
workflowVersionId: null,
frontComponentId: null,
@@ -93,6 +98,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-search-records',
workflowVersionId: null,
frontComponentId: null,
@@ -110,6 +116,7 @@ export const mockedBackendCommandMenuItems: CommandMenuItemFieldsFragment[] = [
},
{
__typename: 'CommandMenuItem',
isActive: true,
id: 'mock-search-records-fallback',
workflowVersionId: null,
frontComponentId: null,
@@ -240,6 +240,9 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
});
@@ -0,0 +1,21 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.13.0', 1781253016028)
export class CommandMenuItemOverridableEntityFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ADD "overrides" jsonb, ADD "isActive" boolean NOT NULL DEFAULT true',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" DROP COLUMN "isActive", DROP COLUMN "overrides"',
);
}
}
@@ -64,6 +64,7 @@ import { DropEmailingDomainDriverColumnFastInstanceCommand } from 'src/database/
import { ViewOverridableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1781114009075-view-overridable-entity';
import { RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781277453604-rename-is-ui-read-only-to-is-ui-editable';
import { BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-slow-1781277480000-backfill-non-ui-creatable-standard-system-objects';
import { CommandMenuItemOverridableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781253016028-command-menu-item-overridable-entity';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -130,4 +131,5 @@ export const INSTANCE_COMMANDS = [
ViewOverridableEntityFastInstanceCommand,
RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand,
BackfillNonUiCreatableStandardSystemObjectsSlowInstanceCommand,
CommandMenuItemOverridableEntityFastInstanceCommand,
];
@@ -45,6 +45,8 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
hotKeys: null,
workflowVersionId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -26,6 +26,7 @@ import { fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow } fr
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
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 { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -198,6 +199,10 @@ export class CommandMenuItemService {
updateCommandMenuItemInput: input,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatPageLayoutMaps: existingFlatPageLayoutMaps,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
@@ -260,14 +265,33 @@ export class CommandMenuItemService {
commandMenuItemId: id,
});
const shouldDeactivate = isCallerOverridingEntity({
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
entityApplicationUniversalIdentifier:
flatCommandMenuItemToDelete.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const deactivatedFlatCommandMenuItem = {
...flatCommandMenuItemToDelete,
isActive: false,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [flatCommandMenuItemToDelete],
flatEntityToUpdate: [],
flatEntityToDelete: shouldDeactivate
? []
: [flatCommandMenuItemToDelete],
flatEntityToUpdate: shouldDeactivate
? [deactivatedFlatCommandMenuItem]
: [],
},
},
workspaceId,
@@ -285,7 +309,9 @@ export class CommandMenuItemService {
}
return fromFlatCommandMenuItemToCommandMenuItemDto(
flatCommandMenuItemToDelete,
shouldDeactivate
? deactivatedFlatCommandMenuItem
: flatCommandMenuItemToDelete,
);
}
@@ -105,6 +105,9 @@ export class CommandMenuItemDTO {
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@IsDateString()
@Field()
createdAt: Date;
@@ -10,14 +10,29 @@ import {
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { type SerializedRelation } from 'twenty-shared/types';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type CommandMenuItemOverrides = {
label?: string;
icon?: string | null;
shortLabel?: string | null;
position?: number;
isPinned?: boolean;
hotKeys?: string[] | null;
availabilityType?: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: SerializedRelation | null;
engineComponentKey?: EngineComponentKey;
pageLayoutId?: SerializedRelation | null;
};
@Entity({ name: 'commandMenuItem', schema: 'core' })
@Index('IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID', [
@@ -40,7 +55,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
`("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`,
)
export class CommandMenuItemEntity
extends SyncableEntity
extends OverridableEntity<CommandMenuItemOverrides>
implements Required<CommandMenuItemEntity>
{
@PrimaryGeneratedColumn('uuid')
@@ -120,3 +135,15 @@ export class CommandMenuItemEntity
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
const COMMAND_MENU_ITEM_OVERRIDABLE_COLUMNS_UPGRADE_COMMAND_NAME =
'2.13.0_CommandMenuItemOverridableEntityFastInstanceCommand_1781253016028';
WasIntroducedInUpgrade({
upgradeCommandName:
COMMAND_MENU_ITEM_OVERRIDABLE_COLUMNS_UPGRADE_COMMAND_NAME,
})(CommandMenuItemEntity.prototype, 'overrides');
WasIntroducedInUpgrade({
upgradeCommandName:
COMMAND_MENU_ITEM_OVERRIDABLE_COLUMNS_UPGRADE_COMMAND_NAME,
})(CommandMenuItemEntity.prototype, 'isActive');
@@ -34,6 +34,7 @@ const baseCommandMenuItem = {
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
payload: { objectMetadataItemId: 'obj-id-1' },
workspaceId: 'ws-id-1',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -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;
};
@@ -41,10 +41,13 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"engineComponentKey",
"payload",
"hotKeys",
"isActive",
"universalOverrides",
],
"propertiesToStringify": [
"payload",
"hotKeys",
"universalOverrides",
],
},
"connectionProvider": {
@@ -1096,27 +1096,37 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
icon: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
icon: { toCompare: true, toStringify: false, universalProperty: undefined },
shortLabel: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
position: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
isPinned: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
availabilityType: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
conditionalAvailabilityExpression: {
toCompare: true,
@@ -1127,6 +1137,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: false,
universalProperty: 'availabilityObjectMetadataUniversalIdentifier',
isOverridable: true,
},
createdAt: {
toCompare: false,
@@ -1147,6 +1158,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
payload: {
toCompare: true,
@@ -1157,6 +1169,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: true,
toStringify: true,
universalProperty: undefined,
isOverridable: true,
},
workflowVersionId: {
toCompare: false,
@@ -1167,6 +1180,18 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toCompare: false,
toStringify: false,
universalProperty: 'pageLayoutUniversalIdentifier',
isOverridable: true,
},
isActive: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: false,
},
overrides: {
toCompare: true,
toStringify: true,
universalProperty: 'universalOverrides',
},
},
navigationMenuItem: {
@@ -138,7 +138,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isDefined(existingFlatObjectMetadata) &&
existingFlatObjectMetadata.isActive;
const { commandMenuItemsToCreate, commandMenuItemsToDelete } =
const { commandMenuItemsToCreate, commandMenuItemsToUpdate } =
this.computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
@@ -175,6 +175,11 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: flatViewFieldsToUpdate,
},
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: commandMenuItemsToUpdate,
},
},
workspaceId,
isSystemBuild: false,
@@ -190,18 +195,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const hasCommandMenuItemChanges =
commandMenuItemsToCreate.length > 0 ||
commandMenuItemsToDelete.length > 0;
if (hasCommandMenuItemChanges) {
if (commandMenuItemsToCreate.length > 0) {
const commandMenuItemMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: commandMenuItemsToCreate,
flatEntityToDelete: commandMenuItemsToDelete,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
@@ -919,12 +920,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
applicationId: string;
}): {
commandMenuItemsToCreate: FlatCommandMenuItem[];
commandMenuItemsToDelete: FlatCommandMenuItem[];
commandMenuItemsToUpdate: FlatCommandMenuItem[];
} {
if (!isDefined(existingFlatObjectMetadata)) {
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return { commandMenuItemsToCreate: [], commandMenuItemsToUpdate: [] };
}
const now = new Date().toISOString();
if (isBeingEnabled) {
const existingCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
@@ -943,28 +946,42 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatCommandMenuItemMaps,
}),
],
commandMenuItemsToDelete: [],
commandMenuItemsToUpdate: [],
};
}
if (!existingCommandMenuItem.isActive) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToUpdate: [
{ ...existingCommandMenuItem, isActive: true, updatedAt: now },
],
};
}
}
if (isBeingDisabled) {
const commandMenuItemToDelete =
const commandMenuItemToDeactivate =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (isDefined(commandMenuItemToDelete)) {
if (
isDefined(commandMenuItemToDeactivate) &&
commandMenuItemToDeactivate.isActive
) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToDelete: [commandMenuItemToDelete],
commandMenuItemsToUpdate: [
{ ...commandMenuItemToDeactivate, isActive: false, updatedAt: now },
],
};
}
}
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return { commandMenuItemsToCreate: [], commandMenuItemsToUpdate: [] };
}
public async findOneWithinWorkspace(
@@ -91,6 +91,9 @@ export const prefillFrontComponentCommandMenuItems = async ({
hotKeys: null,
pageLayoutId: definition.pageLayoutId ?? null,
pageLayoutUniversalIdentifier: definition.pageLayoutId ?? null,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -70,6 +70,9 @@ export const prefillWorkflowCommandMenuItems = async ({
hotKeys: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -76,6 +76,9 @@ export const createStandardCommandMenuItemFlatMetadata = ({
resolvedObjectMetadataUniversalIdentifier,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
isActive: true,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
};
@@ -38,7 +38,9 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = {
configuration: 'configuration',
overrides: 'overrides',
},
commandMenuItem: {},
commandMenuItem: {
overrides: 'overrides',
},
navigationMenuItem: {},
rolePermissionFlag: {},
permissionFlag: {},
@@ -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 {
FlatCreateCommandMenuItemAction,
UniversalCreateCommandMenuItemAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type';
import { fromUniversalOverridesToCommandMenuItemOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/utils/from-universal-overrides-to-command-menu-item-overrides.util';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
@@ -37,6 +39,14 @@ export class CreateCommandMenuItemActionHandlerService extends WorkspaceMigratio
universalForeignKeyValues: action.flatEntity,
});
const overrides = isDefined(action.flatEntity.universalOverrides)
? fromUniversalOverridesToCommandMenuItemOverrides({
universalOverrides: action.flatEntity.universalOverrides,
flatObjectMetadataMaps: allFlatEntityMaps.flatObjectMetadataMaps,
flatPageLayoutMaps: allFlatEntityMaps.flatPageLayoutMaps,
})
: null;
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
metadataName: 'commandMenuItem',
@@ -49,6 +59,7 @@ export class CreateCommandMenuItemActionHandlerService extends WorkspaceMigratio
availabilityObjectMetadataId,
frontComponentId,
pageLayoutId,
overrides,
applicationId: flatApplication.id,
id: action.id ?? v4(),
workspaceId,
@@ -7,6 +7,7 @@ import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-mana
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util';
import { fromUniversalOverridesToCommandMenuItemOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/utils/from-universal-overrides-to-command-menu-item-overrides.util';
import {
FlatUpdateCommandMenuItemAction,
UniversalUpdateCommandMenuItemAction,
@@ -31,11 +32,27 @@ export class UpdateCommandMenuItemActionHandlerService extends WorkspaceMigratio
universalIdentifier: action.universalIdentifier,
});
const update = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'commandMenuItem',
universalUpdate: action.update,
allFlatEntityMaps,
});
const { universalOverrides, ...updateWithResolvedForeignKeys } =
resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'commandMenuItem',
universalUpdate: action.update,
allFlatEntityMaps,
});
const update =
universalOverrides === undefined
? updateWithResolvedForeignKeys
: universalOverrides === null
? { ...updateWithResolvedForeignKeys, overrides: null }
: {
...updateWithResolvedForeignKeys,
overrides: fromUniversalOverridesToCommandMenuItemOverrides({
universalOverrides,
flatObjectMetadataMaps:
allFlatEntityMaps.flatObjectMetadataMaps,
flatPageLayoutMaps: allFlatEntityMaps.flatPageLayoutMaps,
}),
};
return {
type: 'update',
@@ -0,0 +1,58 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { type CommandMenuItemOverrides } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
type UniversalCommandMenuItemOverrides =
FormatRecordSerializedRelationProperties<CommandMenuItemOverrides>;
export const fromUniversalOverridesToCommandMenuItemOverrides = ({
universalOverrides,
flatObjectMetadataMaps,
flatPageLayoutMaps,
}: {
universalOverrides: UniversalCommandMenuItemOverrides;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatPageLayoutMaps: FlatEntityMaps<FlatPageLayout>;
}): CommandMenuItemOverrides => {
const {
availabilityObjectMetadataUniversalIdentifier,
pageLayoutUniversalIdentifier,
...scalarOverrides
} = universalOverrides;
const overrides: CommandMenuItemOverrides = { ...scalarOverrides };
if (availabilityObjectMetadataUniversalIdentifier !== undefined) {
if (availabilityObjectMetadataUniversalIdentifier === null) {
overrides.availabilityObjectMetadataId = null;
} else {
const flatObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: availabilityObjectMetadataUniversalIdentifier,
});
overrides.availabilityObjectMetadataId = flatObjectMetadata?.id ?? null;
}
}
if (pageLayoutUniversalIdentifier !== undefined) {
if (pageLayoutUniversalIdentifier === null) {
overrides.pageLayoutId = null;
} else {
const flatPageLayout =
findFlatEntityByUniversalIdentifier<FlatPageLayout>({
flatEntityMaps: flatPageLayoutMaps,
universalIdentifier: pageLayoutUniversalIdentifier,
});
overrides.pageLayoutId = flatPageLayout?.id ?? null;
}
}
return overrides;
};
@@ -25,6 +25,7 @@ const COMMAND_MENU_ITEM_GQL_FIELDS = `
engineComponentKey
label
icon
isActive
payload {
... on PathCommandMenuItemPayload {
path
@@ -159,7 +160,7 @@ describe('Command menu item side effect on object metadata', () => {
).toBeUndefined();
});
it('should delete the navigation command menu item when a custom object is disabled', async () => {
it('should deactivate the navigation command menu item when a custom object is disabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
@@ -183,7 +184,7 @@ describe('Command menu item side effect on object metadata', () => {
itemsBeforeDisable,
createdObjectMetadataId,
),
).toBeDefined();
).toEqual(expect.objectContaining({ isActive: true }));
await updateOneObjectMetadata({
expectToFail: false,
@@ -206,10 +207,10 @@ describe('Command menu item side effect on object metadata', () => {
itemsAfterDisable,
createdObjectMetadataId,
),
).toBeUndefined();
).toEqual(expect.objectContaining({ isActive: false }));
});
it('should recreate the navigation command menu item when a disabled object is re-enabled', async () => {
it('should reactivate the navigation command menu item when a disabled object is re-enabled', async () => {
const {
data: { createOneObject },
} = await createOneObjectMetadata({
@@ -241,7 +242,7 @@ describe('Command menu item side effect on object metadata', () => {
itemsWhileDisabled,
createdObjectMetadataId,
),
).toBeUndefined();
).toEqual(expect.objectContaining({ isActive: false }));
await updateOneObjectMetadata({
expectToFail: false,
@@ -269,6 +270,7 @@ describe('Command menu item side effect on object metadata', () => {
label: `Go to ${createObjectInput.labelPlural}`,
icon: createObjectInput.icon,
engineComponentKey: EngineComponentKey.NAVIGATION,
isActive: true,
}),
);
});