From 0e01703e25764cc4f3163a971be6832698e6180b Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Fri, 22 May 2026 19:57:16 +0200 Subject: [PATCH] Add dynamic create record commands --- .../components/CreateNewRecordCommand.tsx | 8 +- .../useCreateOneObjectMetadataItem.test.tsx | 9 +- .../hooks/useCreateOneObjectMetadataItem.ts | 57 +++-- .../components/SidePanelPageInfo.tsx | 9 + .../hooks/useSidePanelContextChips.tsx | 19 +- .../components/SidePanelCreateRecordPage.tsx | 29 ++- ...create-record-command-menu-item-payload.ts | 11 +- ...reate-record-command-menu-items.command.ts | 28 +-- ...8-add-payload-to-command-menu-item.util.ts | 9 +- ...-coherence-constraint-sql.constant.spec.ts | 21 ++ ...e-key-coherence-constraint-sql.constant.ts | 6 + .../entities/command-menu-item.entity.ts | 8 +- ...record-flat-command-menu-item.util.spec.ts | 65 +++++ ...eate-record-flat-command-menu-item.util.ts | 46 ++++ .../object-metadata.service.ts | 224 +++++++++--------- 15 files changed, 372 insertions(+), 177 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/__tests__/command-menu-item-engine-key-coherence-constraint-sql.constant.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand.tsx index aabe5ef6a95..b2e26a6d19f 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewRecordCommand.tsx @@ -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, }); }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx index 7bcf75cbff7..81bc6ec5726 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx @@ -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); }); }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts index ace4b5995fb..5992065ba1d 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts @@ -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({ + 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, diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx index af7013649cb..566d266725d 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelPageInfo.tsx @@ -120,6 +120,15 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => { return ; } + if (pageChip.page?.page === SidePanelPages.CreateRecord) { + return ( + } + /> + ); + } + if (pageChip.page?.page === SidePanelPages.NavigationMenuAddItem) { return ( { (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 - ? [] + ? [ + , + ] : [ , ], diff --git a/packages/twenty-front/src/modules/side-panel/pages/create-record/components/SidePanelCreateRecordPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/create-record/components/SidePanelCreateRecordPage.tsx index bba5591b636..382c6f9248c 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/create-record/components/SidePanelCreateRecordPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/create-record/components/SidePanelCreateRecordPage.tsx @@ -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) => { + 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 ( - +
{fieldsWithDefinitions.map( diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1799000001000-allow-create-record-command-menu-item-payload.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1799000001000-allow-create-record-command-menu-item-payload.ts index f20064bf247..9fa09c940f2 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1799000001000-allow-create-record-command-menu-item-payload.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1799000001000-allow-create-record-command-menu-item-payload.ts @@ -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})`, ); } diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1799000000000-backfill-create-record-command-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1799000000000-backfill-create-record-command-menu-items.command.ts index 7903e167471..b48ae57b992 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1799000000000-backfill-create-record-command-menu-items.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1799000000000-backfill-create-record-command-menu-items.command.ts @@ -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; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util.ts b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util.ts index e4a5f2b9bfb..ad2030f8214 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util.ts @@ -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 => { 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})`, ); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/__tests__/command-menu-item-engine-key-coherence-constraint-sql.constant.spec.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/__tests__/command-menu-item-engine-key-coherence-constraint-sql.constant.spec.ts new file mode 100644 index 00000000000..b5e96d83d45 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/__tests__/command-menu-item-engine-key-coherence-constraint-sql.constant.spec.ts @@ -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')`, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant.ts new file mode 100644 index 00000000000..30683c18102 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/constants/command-menu-item-engine-key-coherence-constraint-sql.constant.ts @@ -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)`; diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts index 7e69305331a..ce1ec57f3ca 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity.ts @@ -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 diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-create-record-flat-command-menu-item.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-create-record-flat-command-menu-item.util.spec.ts index bd17401b44f..a78a4a5cd7c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-create-record-flat-command-menu-item.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-create-record-flat-command-menu-item.util.spec.ts @@ -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(); + }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util.ts index 3a8552b0496..7b1db70b2e6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-create-record-flat-command-menu-item.util.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 42d9e963704..7ba5bbee26a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -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 0 || - commandMenuItemsToDelete.length > 0; + commandMenuItemsToDelete.length > 0 || + commandMenuItemsToUpdate.length > 0; if (hasCommandMenuItemChanges) { const commandMenuItemMigrationResult = @@ -207,7 +199,7 @@ export class ObjectMetadataService extends TypeOrmQueryService; }; @@ -1035,98 +1035,110 @@ export class ObjectMetadataService extends TypeOrmQueryService 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(