Add dynamic create record commands

This commit is contained in:
Thomas des Francs
2026-05-22 19:57:16 +02:00
parent 5bef586960
commit 0e01703e25
15 changed files with 372 additions and 177 deletions
@@ -3,7 +3,7 @@ import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
import { isObjectMetadataCommandMenuItemPayload } from '@/command-menu-item/engine-command/utils/isObjectMetadataCommandMenuItemPayload';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { createRecordObjectMetadataItemIdComponentState } from '@/side-panel/pages/create-record/states/createRecordObjectMetadataItemIdComponentState';
import { t } from '@lingui/core/macro';
@@ -11,7 +11,7 @@ import { useStore } from 'jotai';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { getIconTileColorShades, useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const CreateNewRecordCommand = () => {
@@ -58,7 +58,9 @@ export const CreateNewRecordCommand = () => {
page: SidePanelPages.CreateRecord,
pageTitle: t`New ${objectMetadataItem.labelSingular}`,
pageIcon: Icon,
pageIconColor: getIconColorForObjectType(objectMetadataItem.nameSingular),
pageIconColor: getIconTileColorShades(
getObjectColorWithFallback(objectMetadataItem),
).iconColor,
pageId: pageComponentInstanceId,
});
};
@@ -20,6 +20,10 @@ import {
responseData as findManyObjectMetadataItemsResponseData,
} from '@/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems';
const findManyObjectMetadataItemsResult = jest.fn(() => ({
data: findManyObjectMetadataItemsResponseData,
}));
const mocks = [
{
request: {
@@ -48,9 +52,7 @@ const mocks = [
query: findManyObjectMetadataItemsQuery,
variables: {},
},
result: jest.fn(() => ({
data: findManyObjectMetadataItemsResponseData,
})),
result: findManyObjectMetadataItemsResult,
},
{
request: {
@@ -109,6 +111,7 @@ describe('useCreateOneObjectMetadataItem', () => {
});
jestExpectSuccessfulMetadataRequestResult(res);
expect(res.response).toEqual({ data: { createOneObject: responseData } });
expect(findManyObjectMetadataItemsResult).toHaveBeenCalledTimes(1);
});
});
});
@@ -5,13 +5,16 @@ import {
FindManyCommandMenuItemsDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
type ObjectMetadataItemsQuery,
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { splitObjectMetadataGqlResponse } from '@/metadata-store/utils/splitObjectMetadataGqlResponse';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
@@ -60,7 +63,7 @@ export const useCreateOneObjectMetadataItem = () => {
items: [objectData as FlatObjectMetadataItem],
});
const flatFields = fieldsList.map((field) => {
const createdObjectFlatFields = fieldsList.map((field) => {
const { __typename: _fieldTypename, ...fieldData } = field;
return {
@@ -69,29 +72,47 @@ export const useCreateOneObjectMetadataItem = () => {
} as FlatFieldMetadataItem;
});
addToDraft({ key: 'fieldMetadataItems', items: flatFields });
addToDraft({
key: 'fieldMetadataItems',
items: createdObjectFlatFields,
});
applyChanges();
const [viewsResult, navItemsResult, commandMenuItemsResult] =
await Promise.all([
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const [
objectMetadataItemsResult,
viewsResult,
navItemsResult,
commandMenuItemsResult,
] = await Promise.all([
client.query<ObjectMetadataItemsQuery>({
query: FIND_MANY_OBJECT_METADATA_ITEMS,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const fetchedViews = viewsResult.data?.getViews ?? [];
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataGqlResponse(objectMetadataItemsResult.data);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
const {
flatViews,
flatViewFields,
@@ -120,6 +120,15 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
return <SidePanelAskAiInfo />;
}
if (pageChip.page?.page === SidePanelPages.CreateRecord) {
return (
<SidePanelPageInfoLayout
icon={pageChip.Icons[0]}
title={<OverflowingTextWithTooltip text={pageChip.text ?? ''} />}
/>
);
}
if (pageChip.page?.page === SidePanelPages.NavigationMenuAddItem) {
return (
<SidePanelPageInfoLayout
@@ -55,6 +55,11 @@ export const useSidePanelContextChips = () => {
(page) => page.page !== SidePanelPages.CommandMenuDisplay,
);
const getPageIconColor = (pageIconColor: string | undefined) =>
isDefined(pageIconColor) && pageIconColor !== 'currentColor'
? pageIconColor
: themeCssVariables.font.color.tertiary;
return filteredSidePanelNavigationStack
.map((page, index) => {
const isLastChip =
@@ -109,17 +114,17 @@ export const useSidePanelContextChips = () => {
return {
page,
Icons: isLastChip
? [<page.pageIcon size={iconSizeSm} />]
? [
<page.pageIcon
size={iconSizeSm}
color={getPageIconColor(page.pageIconColor)}
/>,
]
: [
<SidePanelContextChipIconWrapper>
<page.pageIcon
size={iconSizeSm}
color={
isDefined(page.pageIconColor) &&
page.pageIconColor !== 'currentColor'
? page.pageIconColor
: themeCssVariables.font.color.tertiary
}
color={getPageIconColor(page.pageIconColor)}
/>
</SidePanelContextChipIconWrapper>,
],
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { type KeyboardEvent, useCallback, useMemo, useState } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -8,17 +8,14 @@ import { FormFieldInput } from '@/object-record/record-field/ui/components/FormF
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isUpdateRecordValueEmpty } from '@/object-record/record-update-multiple/utils/isUpdateRecordValueEmpty';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { createRecordObjectMetadataItemIdComponentState } from '@/side-panel/pages/create-record/states/createRecordObjectMetadataItemIdComponentState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewOrDefaultView } from '@/views/hooks/useViewOrDefaultView';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Key } from 'ts-key-enum';
import { isObjectMetadataManuallyCreatable } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import {
@@ -200,19 +197,29 @@ const SidePanelCreateRecordForm = ({
t,
]);
useHotkeysOnFocusedElement({
keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
callback: () => {
const handleFormKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const isSubmitShortcut =
(event.metaKey || event.ctrlKey) &&
(event.key === 'Enter' || event.code === 'NumpadEnter');
if (!isSubmitShortcut) {
return;
}
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
if (!isSubmitting) {
handleSave();
}
},
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleSave, isSubmitting],
});
[handleSave, isSubmitting],
);
return (
<StyledPage>
<StyledPage onKeyDownCapture={handleFormKeyDown}>
<StyledSectionContainer>
<Section>
{fieldsWithDefinitions.map(
@@ -2,11 +2,10 @@ import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
const COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT =
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE';
const CREATE_RECORD_PAYLOAD_CONSTRAINT_SQL = `("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" = 'CREATE_NEW_RECORD' AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION', 'CREATE_NEW_RECORD') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`;
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
const LEGACY_PAYLOAD_CONSTRAINT_SQL = `("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)`;
@@ -20,7 +19,7 @@ export class AllowCreateRecordCommandMenuItemPayloadFastInstanceCommand
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${CREATE_RECORD_PAYLOAD_CONSTRAINT_SQL})`,
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL})`,
);
}
@@ -11,6 +11,7 @@ import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/deco
import {
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
@@ -117,26 +118,15 @@ export class BackfillCreateRecordCommandMenuItemsCommand extends ActiveOrSuspend
});
if (isDefined(existingCreateCommand)) {
if (
existingCreateCommand.label !== expectedCreateCommand.label ||
existingCreateCommand.shortLabel !==
expectedCreateCommand.shortLabel ||
existingCreateCommand.icon !== expectedCreateCommand.icon ||
existingCreateCommand.conditionalAvailabilityExpression !==
expectedCreateCommand.conditionalAvailabilityExpression ||
JSON.stringify(existingCreateCommand.payload) !==
JSON.stringify(expectedCreateCommand.payload)
) {
commandMenuItemsToUpdate.push({
...existingCreateCommand,
label: expectedCreateCommand.label,
shortLabel: expectedCreateCommand.shortLabel,
icon: expectedCreateCommand.icon,
conditionalAvailabilityExpression:
expectedCreateCommand.conditionalAvailabilityExpression,
payload: expectedCreateCommand.payload,
updatedAt: now,
const updatedCreateCommand =
buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem: existingCreateCommand,
objectMetadata: flatObjectMetadata,
now,
});
if (isDefined(updatedCreateCommand)) {
commandMenuItemsToUpdate.push(updatedCreateCommand);
}
return undefined;
@@ -1,13 +1,18 @@
import { type QueryRunner } from 'typeorm';
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
export const addPayloadCheckConstraintToCommandMenuItem = async (
queryRunner: QueryRunner,
): Promise<void> => {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE"`,
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}"`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE" CHECK (("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" = 'CREATE_NEW_RECORD' AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION', 'CREATE_NEW_RECORD') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL))`,
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT}" CHECK (${COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL})`,
);
};
@@ -0,0 +1,21 @@
import { COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL } from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
describe('COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL', () => {
it('constrains CREATE_NEW_RECORD payloads to null or object metadata payloads', () => {
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"engineComponentKey" = 'CREATE_NEW_RECORD'`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" IS NULL OR`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" ? 'objectMetadataItemId'`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`"payload" ->> 'objectMetadataItemId' <> ''`,
);
expect(COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL).toContain(
`NOT ("payload" ? 'path')`,
);
});
});
@@ -0,0 +1,6 @@
export const COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT =
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE';
const CREATE_NEW_RECORD_PAYLOAD_SQL = `("payload" IS NULL OR (jsonb_typeof("payload") = 'object' AND "payload" ? 'objectMetadataItemId' AND jsonb_typeof("payload" -> 'objectMetadataItemId') = 'string' AND "payload" ->> 'objectMetadataItemId' <> '' AND NOT ("payload" ? 'path')))`;
export const COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL = `("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" = 'CREATE_NEW_RECORD' AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND ${CREATE_NEW_RECORD_PAYLOAD_SQL}) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION', 'CREATE_NEW_RECORD') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`;
@@ -11,6 +11,10 @@ import {
UpdateDateColumn,
} from 'typeorm';
import {
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
} from 'src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant';
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';
@@ -36,8 +40,8 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'workspaceId',
])
@Check(
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
`("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" = 'CREATE_NEW_RECORD' AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION', 'CREATE_NEW_RECORD') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT,
COMMAND_MENU_ITEM_ENGINE_KEY_COHERENCE_CONSTRAINT_SQL,
)
export class CommandMenuItemEntity
extends SyncableEntity
@@ -8,6 +8,7 @@ import {
CREATE_RECORD_INTERPOLATED_LABEL,
CREATE_RECORD_INTERPOLATED_SHORT_LABEL,
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
@@ -60,4 +61,68 @@ describe('buildCreateRecordFlatCommandMenuItem', () => {
updatedAt: '2026-05-22T00:00:00.000Z',
});
});
it('updates stale create command metadata while preserving stable fields', () => {
const existingCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
commandMenuItemId: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
now: '2026-05-22T00:00:00.000Z',
});
const result = buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem,
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'organization',
},
now: '2026-05-23T00:00:00.000Z',
});
expect(result).toMatchObject({
id: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
conditionalAvailabilityExpression:
'targetObjectWritePermissions.organization and not (pageType == "INDEX_PAGE" and objectMetadataItem.nameSingular == "organization")',
payload: { objectMetadataItemId: 'object-metadata-id' },
createdAt: '2026-05-22T00:00:00.000Z',
updatedAt: '2026-05-23T00:00:00.000Z',
});
});
it('does not update an already current create command', () => {
const existingCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
commandMenuItemId: 'command-menu-item-id',
applicationId: 'application-id',
workspaceId: 'workspace-id',
position: 42,
now: '2026-05-22T00:00:00.000Z',
});
const result = buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem,
objectMetadata: {
id: 'object-metadata-id',
universalIdentifier: '5da6fdc9-48db-4cd1-b41c-907d8edaaf7b',
nameSingular: 'company',
},
now: '2026-05-23T00:00:00.000Z',
});
expect(result).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import isEqual from 'lodash.isequal';
import { v5 } from 'uuid';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
@@ -67,3 +68,48 @@ export const buildCreateRecordFlatCommandMenuItem = ({
updatedAt: now,
};
};
export const buildUpdatedCreateRecordFlatCommandMenuItem = ({
existingCommandMenuItem,
objectMetadata,
now,
}: {
existingCommandMenuItem: FlatCommandMenuItem;
objectMetadata: {
id: string;
universalIdentifier: string;
nameSingular: string;
};
now: string;
}): FlatCommandMenuItem | undefined => {
const expectedCommandMenuItem = buildCreateRecordFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: existingCommandMenuItem.id,
applicationId: existingCommandMenuItem.applicationId,
workspaceId: existingCommandMenuItem.workspaceId,
position: existingCommandMenuItem.position,
now,
});
if (
existingCommandMenuItem.label === expectedCommandMenuItem.label &&
existingCommandMenuItem.shortLabel === expectedCommandMenuItem.shortLabel &&
existingCommandMenuItem.icon === expectedCommandMenuItem.icon &&
existingCommandMenuItem.conditionalAvailabilityExpression ===
expectedCommandMenuItem.conditionalAvailabilityExpression &&
isEqual(existingCommandMenuItem.payload, expectedCommandMenuItem.payload)
) {
return undefined;
}
return {
...existingCommandMenuItem,
label: expectedCommandMenuItem.label,
shortLabel: expectedCommandMenuItem.shortLabel,
icon: expectedCommandMenuItem.icon,
conditionalAvailabilityExpression:
expectedCommandMenuItem.conditionalAvailabilityExpression,
payload: expectedCommandMenuItem.payload,
updatedAt: now,
};
};
@@ -18,6 +18,7 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import {
buildCreateRecordFlatCommandMenuItem,
buildUpdatedCreateRecordFlatCommandMenuItem,
CREATE_RECORD_COMMAND_UUID_NAMESPACE,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util';
import {
@@ -129,30 +130,20 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityId: updateObjectInput.id,
});
const {
commandMenuItemsToCreate,
commandMenuItemsToDelete,
commandMenuItemsToUpdate,
} = this.computeCommandMenuItemChangesForObjectUpdate({
existingFlatObjectMetadata,
flatObjectMetadataToUpdate,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
});
const isActiveChangeDefined = isDefined(updateObjectInput.update.isActive);
const isBeingEnabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === true &&
isDefined(existingFlatObjectMetadata) &&
!existingFlatObjectMetadata.isActive;
const isBeingDisabled =
isActiveChangeDefined &&
updateObjectInput.update.isActive === false &&
isDefined(existingFlatObjectMetadata) &&
existingFlatObjectMetadata.isActive;
const { commandMenuItemsToCreate, commandMenuItemsToDelete } =
this.computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
existingFlatObjectMetadata,
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
workspaceId,
applicationId: twentyStandardFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -197,7 +188,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const hasCommandMenuItemChanges =
commandMenuItemsToCreate.length > 0 ||
commandMenuItemsToDelete.length > 0;
commandMenuItemsToDelete.length > 0 ||
commandMenuItemsToUpdate.length > 0;
if (hasCommandMenuItemChanges) {
const commandMenuItemMigrationResult =
@@ -207,7 +199,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
commandMenuItem: {
flatEntityToCreate: commandMenuItemsToCreate,
flatEntityToDelete: commandMenuItemsToDelete,
flatEntityToUpdate: [],
flatEntityToUpdate: commandMenuItemsToUpdate,
},
},
workspaceId,
@@ -250,10 +242,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
]);
}
if (isActiveChangeDefined) {
if (isActiveChangeDefined || hasCommandMenuItemChanges) {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps', 'flatCommandMenuItemMaps'],
flatMapsKeys: isActiveChangeDefined
? ['flatNavigationMenuItemMaps', 'flatCommandMenuItemMaps']
: ['flatCommandMenuItemMaps'],
});
}
@@ -1017,17 +1011,23 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
}
private computeCommandMenuItemChangesForActiveToggle({
isBeingEnabled,
isBeingDisabled,
private computeCommandMenuItemChangesForObjectUpdate({
existingFlatObjectMetadata,
flatObjectMetadataToUpdate,
flatCommandMenuItemMaps,
workspaceId,
applicationId,
}: {
isBeingEnabled: boolean;
isBeingDisabled: boolean;
existingFlatObjectMetadata: FlatObjectMetadata | undefined;
flatObjectMetadataToUpdate: {
universalIdentifier: string;
labelPlural: string;
icon: string | null;
nameSingular: string;
shortcut: string | null;
isActive: boolean;
isSystem: boolean;
};
flatCommandMenuItemMaps: {
byUniversalIdentifier: Record<string, FlatCommandMenuItem | undefined>;
};
@@ -1035,98 +1035,110 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
applicationId: string;
}): {
commandMenuItemsToCreate: FlatCommandMenuItem[];
commandMenuItemsToUpdate: FlatCommandMenuItem[];
commandMenuItemsToDelete: FlatCommandMenuItem[];
} {
if (!isDefined(existingFlatObjectMetadata)) {
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return {
commandMenuItemsToCreate: [],
commandMenuItemsToUpdate: [],
commandMenuItemsToDelete: [],
};
}
if (isBeingEnabled) {
const commandMenuItemsToCreate: FlatCommandMenuItem[] = [];
let nextPosition = this.computeNextCommandMenuItemPosition(
const updatedFlatObjectMetadataWithId = {
...flatObjectMetadataToUpdate,
id: existingFlatObjectMetadata.id,
};
const existingNavigationCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
const existingCreateRecordCommandMenuItem =
this.findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
if (!flatObjectMetadataToUpdate.isActive) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToUpdate: [],
commandMenuItemsToDelete: [
existingNavigationCommandMenuItem,
existingCreateRecordCommandMenuItem,
].filter(isDefined),
};
}
const commandMenuItemsToCreate: FlatCommandMenuItem[] = [];
const commandMenuItemsToUpdate: FlatCommandMenuItem[] = [];
const commandMenuItemsToDelete: FlatCommandMenuItem[] = [];
let nextPosition = this.computeNextCommandMenuItemPosition(
flatCommandMenuItemMaps,
);
if (!isDefined(existingNavigationCommandMenuItem)) {
commandMenuItemsToCreate.push(
this.buildFlatNavigationCommandMenuItem({
objectMetadata: updatedFlatObjectMetadataWithId,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
}
const existingNavigationCommandMenuItem =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
const isManuallyCreatableAfterUpdate = isObjectMetadataManuallyCreatable(
flatObjectMetadataToUpdate,
);
if (!isDefined(existingNavigationCommandMenuItem)) {
commandMenuItemsToCreate.push(
this.buildFlatNavigationCommandMenuItem({
objectMetadata: existingFlatObjectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
if (!isManuallyCreatableAfterUpdate) {
if (isDefined(existingCreateRecordCommandMenuItem)) {
commandMenuItemsToDelete.push(existingCreateRecordCommandMenuItem);
}
const existingCreateRecordCommandMenuItem =
this.findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
return {
commandMenuItemsToCreate,
commandMenuItemsToUpdate,
commandMenuItemsToDelete,
};
}
if (!isDefined(existingCreateRecordCommandMenuItem)) {
commandMenuItemsToCreate.push(
this.buildFlatCreateRecordCommandMenuItem({
objectMetadata: updatedFlatObjectMetadataWithId,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
} else {
const updatedCreateRecordCommandMenuItem =
buildUpdatedCreateRecordFlatCommandMenuItem({
existingCommandMenuItem: existingCreateRecordCommandMenuItem,
objectMetadata: updatedFlatObjectMetadataWithId,
now: new Date().toISOString(),
});
if (
isObjectMetadataManuallyCreatable({
...existingFlatObjectMetadata,
isActive: true,
}) &&
!isDefined(existingCreateRecordCommandMenuItem)
) {
commandMenuItemsToCreate.push(
this.buildFlatCreateRecordCommandMenuItem({
objectMetadata: existingFlatObjectMetadata,
workspaceId,
applicationId,
flatCommandMenuItemMaps,
position: nextPosition++,
}),
);
}
if (commandMenuItemsToCreate.length > 0) {
return {
commandMenuItemsToCreate,
commandMenuItemsToDelete: [],
};
if (isDefined(updatedCreateRecordCommandMenuItem)) {
commandMenuItemsToUpdate.push(updatedCreateRecordCommandMenuItem);
}
}
if (isBeingDisabled) {
const navigationCommandMenuItemToDelete =
this.findNavigationCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
const createRecordCommandMenuItemToDelete =
this.findCreateRecordCommandMenuItemForObject({
objectUniversalIdentifier:
existingFlatObjectMetadata.universalIdentifier,
flatCommandMenuItemMaps,
});
const commandMenuItemsToDelete = [
navigationCommandMenuItemToDelete,
createRecordCommandMenuItemToDelete,
].filter(isDefined);
if (commandMenuItemsToDelete.length > 0) {
return {
commandMenuItemsToCreate: [],
commandMenuItemsToDelete,
};
}
}
return { commandMenuItemsToCreate: [], commandMenuItemsToDelete: [] };
return {
commandMenuItemsToCreate,
commandMenuItemsToUpdate,
commandMenuItemsToDelete,
};
}
public async findOneWithinWorkspace(