From 51a2a90b0af058ef6ee2adc95d668a99a7230f3b Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:05:06 +0530 Subject: [PATCH] feat: CommandMenuItem entity and FrontComponent support in command menu (#17555) https://github.com/user-attachments/assets/6f172ffc-d43d-42fd-a26b-94f591fb767e --- packages/twenty-front/codegen-metadata.cjs | 1 + .../src/generated-metadata/graphql.ts | 64 ++++++- .../twenty-front/src/generated/graphql.ts | 48 +++++- .../action-menu/actions/types/ActionType.ts | 1 + .../ActionMenuContextProviderDefault.tsx | 5 + ...tionMenuContextProviderWorkflowObjects.tsx | 19 ++- .../fragments/commandMenuItemFragment.ts | 18 ++ .../queries/findManyCommandMenuItems.ts | 11 ++ ...seCommandMenuItemFrontComponentActions.tsx | 158 ++++++++++++++++++ .../command-menu/components/CommandMenu.tsx | 9 +- .../constants/CommandMenuPagesConfig.tsx | 2 + .../hooks/useCommandMenuActions.tsx | 14 ++ .../hooks/useMatchingCommandMenuActions.ts | 12 ++ .../useOpenFrontComponentInCommandMenu.ts | 47 ++++++ .../CommandMenuFrontComponentPage.tsx | 16 ++ .../viewableFrontComponentIdComponentState.ts | 10 ++ .../command-menu/types/CommandMenuPages.ts | 1 + ...front-component-id-to-command-menu-item.ts | 55 ++++++ .../command-menu-item.exception.ts | 3 + .../command-menu-item.module.ts | 2 + .../command-menu-item.resolver.ts | 29 +++- .../dtos/command-menu-item.dto.ts | 15 +- .../dtos/create-command-menu-item.input.ts | 11 +- .../entities/command-menu-item.entity.ts | 24 ++- ...item-graphql-api-exception-handler.util.ts | 1 + .../flat-command-menu-item.module.ts | 2 + ...lat-command-menu-item-map-cache.service.ts | 18 +- ...m-entity-to-flat-command-menu-item.util.ts | 19 +++ ...o-flat-command-menu-item-to-create.util.ts | 22 ++- ...menu-item-to-command-menu-item-dto.util.ts | 3 +- .../all-metadata-relations.constant.ts | 5 + ...quired-metadata-for-validation.constant.ts | 1 + ...a-related-metadata-names.util.spec.ts.snap | 3 +- ...ata-names-children-first.util.spec.ts.snap | 2 +- ...nt-and-command-menu-item-seeds.constant.ts | 7 + ...t-and-command-menu-item-data-seeds.util.ts | 76 +++++++++ .../core/utils/seed-feature-flags.util.ts | 5 + ...-components-and-command-menu-items.util.ts | 80 +++++++++ .../dev-seeder/services/dev-seeder.service.ts | 20 ++- ...ce-migration-build-orchestrator.service.ts | 4 +- ...lat-command-menu-item-validator.service.ts | 13 ++ ...enu-item-creation.integration-spec.ts.snap | 22 ++- ...and-menu-item-creation.integration-spec.ts | 10 ++ ...and-menu-item-creation.integration-spec.ts | 40 +++++ ...te-command-menu-item-query-factory.util.ts | 5 + 45 files changed, 899 insertions(+), 34 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu-item/graphql/fragments/commandMenuItemFragment.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/graphql/queries/findManyCommandMenuItems.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/hooks/useOpenFrontComponentInCommandMenu.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/front-component/components/CommandMenuFrontComponentPage.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1769654418252-add-front-component-id-to-command-menu-item.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/front-component-and-command-menu-item-seeds.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 56b8dd80dc..55334b9382 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -24,6 +24,7 @@ module.exports = { './src/modules/analytics/graphql/**/*.{ts,tsx}', './src/modules/object-metadata/graphql/**/*.{ts,tsx}', './src/modules/navigation-menu-item/graphql/**/*.{ts,tsx}', + './src/modules/command-menu-item/graphql/**/*.{ts,tsx}', './src/modules/attachments/graphql/**/*.{ts,tsx}', './src/modules/file/graphql/**/*.{ts,tsx}', './src/modules/onboarding/graphql/**/*.{ts,tsx}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 83fe92a8c9..983075af30 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -679,12 +679,14 @@ export type CommandMenuItem = { availabilityObjectMetadataId?: Maybe; availabilityType: CommandMenuItemAvailabilityType; createdAt: Scalars['DateTime']; + frontComponent?: Maybe; + frontComponentId?: Maybe; icon?: Maybe; id: Scalars['UUID']; isPinned: Scalars['Boolean']; label: Scalars['String']; updatedAt: Scalars['DateTime']; - workflowVersionId: Scalars['UUID']; + workflowVersionId?: Maybe; }; export enum CommandMenuItemAvailabilityType { @@ -923,10 +925,11 @@ export type CreateApprovedAccessDomainInput = { export type CreateCommandMenuItemInput = { availabilityObjectMetadataId?: InputMaybe; availabilityType?: InputMaybe; + frontComponentId?: InputMaybe; icon?: InputMaybe; isPinned?: InputMaybe; label: Scalars['String']; - workflowVersionId: Scalars['UUID']; + workflowVersionId?: InputMaybe; }; export type CreateDraftFromWorkflowVersionInput = { @@ -5847,6 +5850,13 @@ export type ListPlansQueryVariables = Exact<{ [key: string]: never; }>; export type ListPlansQuery = { __typename?: 'Query', listPlans: Array<{ __typename?: 'BillingPlanOutput', planKey: BillingPlanKey, licensedProducts: Array<{ __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, prices?: Array<{ __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }>, meteredProducts: Array<{ __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, prices?: Array<{ __typename?: 'BillingPriceMetered', priceUsageType: BillingUsageType, recurringInterval: SubscriptionInterval, stripePriceId: string, tiers: Array<{ __typename?: 'BillingPriceTier', flatAmount?: number | null, unitAmount?: number | null, upTo?: number | null }> }> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } }> }> }; +export type CommandMenuItemFieldsFragment = { __typename?: 'CommandMenuItem', id: string, workflowVersionId?: string | null, frontComponentId?: string | null, label: string, icon?: string | null, isPinned: boolean, availabilityType: CommandMenuItemAvailabilityType, availabilityObjectMetadataId?: string | null, frontComponent?: { __typename?: 'FrontComponent', id: string, name: string } | null }; + +export type FindManyCommandMenuItemsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FindManyCommandMenuItemsQuery = { __typename?: 'Query', commandMenuItems: Array<{ __typename?: 'CommandMenuItem', id: string, workflowVersionId?: string | null, frontComponentId?: string | null, label: string, icon?: string | null, isPinned: boolean, availabilityType: CommandMenuItemAvailabilityType, availabilityObjectMetadataId?: string | null, frontComponent?: { __typename?: 'FrontComponent', id: string, name: string } | null }> }; + export type CreateFileMutationVariables = Exact<{ file: Scalars['Upload']; }>; @@ -7198,6 +7208,22 @@ export const BillingPriceMeteredFragmentFragmentDoc = gql` } } `; +export const CommandMenuItemFieldsFragmentDoc = gql` + fragment CommandMenuItemFields on CommandMenuItem { + id + workflowVersionId + frontComponentId + frontComponent { + id + name + } + label + icon + isPinned + availabilityType + availabilityObjectMetadataId +} + `; export const NavigationMenuItemFieldsFragmentDoc = gql` fragment NavigationMenuItemFields on NavigationMenuItem { id @@ -10078,6 +10104,40 @@ export function useListPlansLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions< export type ListPlansQueryHookResult = ReturnType; export type ListPlansLazyQueryHookResult = ReturnType; export type ListPlansQueryResult = Apollo.QueryResult; +export const FindManyCommandMenuItemsDocument = gql` + query FindManyCommandMenuItems { + commandMenuItems { + ...CommandMenuItemFields + } +} + ${CommandMenuItemFieldsFragmentDoc}`; + +/** + * __useFindManyCommandMenuItemsQuery__ + * + * To run a query within a React component, call `useFindManyCommandMenuItemsQuery` and pass it any options that fit your needs. + * When your component renders, `useFindManyCommandMenuItemsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindManyCommandMenuItemsQuery({ + * variables: { + * }, + * }); + */ +export function useFindManyCommandMenuItemsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindManyCommandMenuItemsDocument, options); + } +export function useFindManyCommandMenuItemsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindManyCommandMenuItemsDocument, options); + } +export type FindManyCommandMenuItemsQueryHookResult = ReturnType; +export type FindManyCommandMenuItemsLazyQueryHookResult = ReturnType; +export type FindManyCommandMenuItemsQueryResult = Apollo.QueryResult; export const CreateFileDocument = gql` mutation CreateFile($file: Upload!) { createFile(file: $file) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 6204357217..96f99986bc 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -679,12 +679,14 @@ export type CommandMenuItem = { availabilityObjectMetadataId?: Maybe; availabilityType: CommandMenuItemAvailabilityType; createdAt: Scalars['DateTime']; + frontComponent?: Maybe; + frontComponentId?: Maybe; icon?: Maybe; id: Scalars['UUID']; isPinned: Scalars['Boolean']; label: Scalars['String']; updatedAt: Scalars['DateTime']; - workflowVersionId: Scalars['UUID']; + workflowVersionId?: Maybe; }; export enum CommandMenuItemAvailabilityType { @@ -919,10 +921,11 @@ export type CreateApprovedAccessDomainInput = { export type CreateCommandMenuItemInput = { availabilityObjectMetadataId?: InputMaybe; availabilityType?: InputMaybe; + frontComponentId?: InputMaybe; icon?: InputMaybe; isPinned?: InputMaybe; label: Scalars['String']; - workflowVersionId: Scalars['UUID']; + workflowVersionId?: InputMaybe; }; export type CreateDraftFromWorkflowVersionInput = { @@ -954,6 +957,11 @@ export type CreateFieldInput = { type: FieldMetadataType; }; +export type CreateFrontComponentInput = { + id?: InputMaybe; + name: Scalars['String']; +}; + export type CreateLogicFunctionInput = { builtHandlerPath?: InputMaybe; code?: InputMaybe; @@ -1984,6 +1992,7 @@ export type Mutation = { createDraftFromWorkflowVersion: WorkflowVersionDto; createEmailingDomain: EmailingDomain; createFile: File; + createFrontComponent: FrontComponent; createManyCoreViewFields: Array; createManyCoreViewGroups: Array; createOIDCIdentityProvider: SetupSsoOutput; @@ -2016,6 +2025,7 @@ export type Mutation = { deleteDatabaseConfigVariable: Scalars['Boolean']; deleteEmailingDomain: Scalars['Boolean']; deleteFile: File; + deleteFrontComponent: FrontComponent; deleteJobs: DeleteJobsResponse; deleteOneAgent: Agent; deleteOneField: Field; @@ -2095,6 +2105,7 @@ export type Mutation = { updateCoreViewGroup: CoreViewGroup; updateCoreViewSort: CoreViewSort; updateDatabaseConfigVariable: Scalars['Boolean']; + updateFrontComponent: FrontComponent; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; updateOneApplicationVariable: Scalars['Boolean']; @@ -2254,6 +2265,11 @@ export type MutationCreateFileArgs = { }; +export type MutationCreateFrontComponentArgs = { + input: CreateFrontComponentInput; +}; + + export type MutationCreateManyCoreViewFieldsArgs = { inputs: Array; }; @@ -2408,6 +2424,11 @@ export type MutationDeleteFileArgs = { }; +export type MutationDeleteFrontComponentArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteJobsArgs = { jobIds: Array; queueName: Scalars['String']; @@ -2791,6 +2812,11 @@ export type MutationUpdateDatabaseConfigVariableArgs = { }; +export type MutationUpdateFrontComponentArgs = { + input: UpdateFrontComponentInput; +}; + + export type MutationUpdateLabPublicFeatureFlagArgs = { input: UpdateLabPublicFeatureFlagInput; }; @@ -3419,6 +3445,8 @@ export type Query = { findOneLogicFunction: LogicFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + frontComponent?: Maybe; + frontComponents: Array; getAddressDetails: PlaceDetailsResult; getApprovedAccessDomains: Array; getAutoCompleteAddress: Array; @@ -3528,6 +3556,11 @@ export type QueryFindWorkspaceFromInviteHashArgs = { }; +export type QueryFrontComponentArgs = { + id: Scalars['UUID']; +}; + + export type QueryGetAddressDetailsArgs = { placeId: Scalars['String']; token: Scalars['String']; @@ -4378,6 +4411,17 @@ export type UpdateFieldInput = { settings?: InputMaybe; }; +export type UpdateFrontComponentInput = { + /** The id of the front component to update */ + id: Scalars['UUID']; + /** The front component fields to update */ + update: UpdateFrontComponentInputUpdates; +}; + +export type UpdateFrontComponentInputUpdates = { + name?: InputMaybe; +}; + export type UpdateLabPublicFeatureFlagInput = { publicFeatureFlag: Scalars['String']; value: Scalars['Boolean']; diff --git a/packages/twenty-front/src/modules/action-menu/actions/types/ActionType.ts b/packages/twenty-front/src/modules/action-menu/actions/types/ActionType.ts index b559e33548..9f1b978932 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/types/ActionType.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/types/ActionType.ts @@ -1,6 +1,7 @@ export enum ActionType { Standard = 'Standard', WorkflowRun = 'WorkflowRun', + FrontComponent = 'FrontComponent', Fallback = 'Fallback', Navigation = 'Navigation', } diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderDefault.tsx b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderDefault.tsx index 6a953a9511..256383cb29 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderDefault.tsx +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderDefault.tsx @@ -6,6 +6,7 @@ import { } from '@/action-menu/contexts/ActionMenuContext'; import { useRegisteredActions } from '@/action-menu/hooks/useRegisteredActions'; import { useShouldActionBeRegisteredParams } from '@/action-menu/hooks/useShouldActionBeRegisteredParams'; +import { useCommandMenuItemFrontComponentActions } from '@/command-menu-item/hooks/useCommandMenuItemFrontComponentActions'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; @@ -49,6 +50,9 @@ export const ActionMenuContextProviderDefault = ({ const runWorkflowRecordAgnosticActions = useRunWorkflowRecordAgnosticActions(); + const commandMenuItemFrontComponentActions = + useCommandMenuItemFrontComponentActions(); + return ( diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderWorkflowObjects.tsx b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderWorkflowObjects.tsx index 10e1ffb9d2..d2fc025099 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderWorkflowObjects.tsx +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProviderWorkflowObjects.tsx @@ -5,6 +5,7 @@ import { } from '@/action-menu/contexts/ActionMenuContext'; import { useRegisteredActions } from '@/action-menu/hooks/useRegisteredActions'; import { useShouldActionBeRegisteredParams } from '@/action-menu/hooks/useShouldActionBeRegisteredParams'; +import { useCommandMenuItemFrontComponentActions } from '@/command-menu-item/hooks/useCommandMenuItemFrontComponentActions'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; @@ -49,13 +50,20 @@ const ActionMenuContextProviderWorkflowObjectsContent = ({ const runWorkflowRecordAgnosticActions = useRunWorkflowRecordAgnosticActions(); + const commandMenuItemFrontComponentActions = + useCommandMenuItemFrontComponentActions(); + return ( {children} @@ -86,13 +94,20 @@ const ActionMenuContextProviderWorkflowObjectsWithoutWorkflow = ({ const runWorkflowRecordAgnosticActions = useRunWorkflowRecordAgnosticActions(); + const commandMenuItemFrontComponentActions = + useCommandMenuItemFrontComponentActions(); + return ( {children} diff --git a/packages/twenty-front/src/modules/command-menu-item/graphql/fragments/commandMenuItemFragment.ts b/packages/twenty-front/src/modules/command-menu-item/graphql/fragments/commandMenuItemFragment.ts new file mode 100644 index 0000000000..08ee2ff4d6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/graphql/fragments/commandMenuItemFragment.ts @@ -0,0 +1,18 @@ +import { gql } from '@apollo/client'; + +export const COMMAND_MENU_ITEM_FRAGMENT = gql` + fragment CommandMenuItemFields on CommandMenuItem { + id + workflowVersionId + frontComponentId + frontComponent { + id + name + } + label + icon + isPinned + availabilityType + availabilityObjectMetadataId + } +`; diff --git a/packages/twenty-front/src/modules/command-menu-item/graphql/queries/findManyCommandMenuItems.ts b/packages/twenty-front/src/modules/command-menu-item/graphql/queries/findManyCommandMenuItems.ts new file mode 100644 index 0000000000..a44975f05c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/graphql/queries/findManyCommandMenuItems.ts @@ -0,0 +1,11 @@ +import { COMMAND_MENU_ITEM_FRAGMENT } from '@/command-menu-item/graphql/fragments/commandMenuItemFragment'; +import { gql } from '@apollo/client'; + +export const FIND_MANY_COMMAND_MENU_ITEMS = gql` + ${COMMAND_MENU_ITEM_FRAGMENT} + query FindManyCommandMenuItems { + commandMenuItems { + ...CommandMenuItemFields + } + } +`; diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx new file mode 100644 index 0000000000..731a6221ce --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx @@ -0,0 +1,158 @@ +import { Action } from '@/action-menu/actions/components/Action'; +import { ActionScope } from '@/action-menu/actions/types/ActionScope'; +import { ActionType } from '@/action-menu/actions/types/ActionType'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; +import { useOpenFrontComponentInCommandMenu } from '@/command-menu/hooks/useOpenFrontComponentInCommandMenu'; +import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; +import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { type IconComponent, useIcons } from 'twenty-ui/display'; + +import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon'; +import { + type CommandMenuItemFieldsFragment, + CommandMenuItemAvailabilityType, + useFindManyCommandMenuItemsQuery, +} from '~/generated-metadata/graphql'; +import { FeatureFlagKey } from '~/generated/graphql'; + +type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & { + frontComponentId: string; +}; + +type BuildActionFromItemParams = { + item: CommandMenuItemWithFrontComponent; + scope: ActionScope; + index: number; + isPinned: boolean; + getIcon: ReturnType['getIcon']; + openFrontComponentInCommandMenu: (params: { + frontComponentId: string; + pageTitle: string; + pageIcon: IconComponent; + }) => void; +}; + +const buildActionFromItem = ({ + item, + scope, + index, + isPinned, + getIcon, + openFrontComponentInCommandMenu, +}: BuildActionFromItemParams) => { + const displayLabel = item.label; + + const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON); + + return { + type: ActionType.FrontComponent, + key: `command-menu-item-front-component-${item.id}`, + scope, + label: displayLabel, + shortLabel: displayLabel, + position: index, + isPinned, + Icon, + shouldBeRegistered: () => true, + component: ( + + openFrontComponentInCommandMenu({ + frontComponentId: item.frontComponentId, + pageTitle: displayLabel, + pageIcon: Icon, + }) + } + closeSidePanelOnCommandMenuListActionExecution={false} + /> + ), + }; +}; + +export const useCommandMenuItemFrontComponentActions = () => { + const { getIcon } = useIcons(); + const { openFrontComponentInCommandMenu } = + useOpenFrontComponentInCommandMenu(); + + const isPageInEditMode = useRecoilComponentValue( + contextStoreIsPageInEditModeComponentState, + ); + + const { actionMenuType } = useContext(ActionMenuContext); + + const currentObjectMetadataItemId = useRecoilComponentValue( + contextStoreCurrentObjectMetadataItemIdComponentState, + ); + + const targetedRecordsRule = useRecoilComponentValue( + contextStoreTargetedRecordsRuleComponentState, + ); + + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); + + const { data } = useFindManyCommandMenuItemsQuery({ + skip: + !isCommandMenuItemEnabled || + (actionMenuType !== 'command-menu' && + actionMenuType !== 'command-menu-show-page-action-menu-dropdown'), + }); + + const frontComponentItems = + data?.commandMenuItems?.filter( + (item): item is CommandMenuItemWithFrontComponent => + isDefined(item.frontComponentId), + ) ?? []; + + const selectedRecordCount = + targetedRecordsRule.mode === 'selection' + ? targetedRecordsRule.selectedRecordIds.length + : 0; + + const objectMatches = (item: CommandMenuItemWithFrontComponent) => + !isDefined(item.availabilityObjectMetadataId) || + item.availabilityObjectMetadataId === currentObjectMetadataItemId; + + const globalItems = frontComponentItems.filter( + (item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL, + ); + + const recordScopedItems = frontComponentItems.filter((item) => { + if (!objectMatches(item)) return false; + if (item.availabilityType === CommandMenuItemAvailabilityType.SINGLE_RECORD) + return selectedRecordCount === 1; + if (item.availabilityType === CommandMenuItemAvailabilityType.BULK_RECORDS) + return selectedRecordCount >= 1; + return false; + }); + + const globalActions = globalItems.map((item, index) => + buildActionFromItem({ + item, + scope: ActionScope.Global, + index, + isPinned: !isPageInEditMode && item.isPinned, + getIcon, + openFrontComponentInCommandMenu, + }), + ); + + const recordScopedActions = recordScopedItems.map((item, index) => + buildActionFromItem({ + item, + scope: ActionScope.RecordSelection, + index, + isPinned: !isPageInEditMode && item.isPinned, + getIcon, + openFrontComponentInCommandMenu, + }), + ); + + return [...globalActions, ...recordScopedActions]; +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index f6cfd96069..53fd2266c5 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -28,8 +28,10 @@ export const CommandMenu = () => { matchingStandardActionRecordSelectionActions, matchingStandardActionObjectActions, matchingWorkflowRunRecordSelectionActions, + matchingFrontComponentRecordSelectionActions, matchingStandardActionGlobalActions, matchingWorkflowRunGlobalActions, + matchingFrontComponentGlobalActions, matchingNavigateActions, fallbackActions, matchingCreateRelatedRecordActions, @@ -53,9 +55,9 @@ export const CommandMenu = () => { const commandGroups: ActionGroupConfig[] = [ { heading: t`Record Selection`, - items: matchingStandardActionRecordSelectionActions.concat( - matchingWorkflowRunRecordSelectionActions, - ), + items: matchingStandardActionRecordSelectionActions + .concat(matchingWorkflowRunRecordSelectionActions) + .concat(matchingFrontComponentRecordSelectionActions), }, { heading: t`Create Related Record`, @@ -69,6 +71,7 @@ export const CommandMenu = () => { heading: t`Global`, items: matchingStandardActionGlobalActions .concat(matchingWorkflowRunGlobalActions) + .concat(matchingFrontComponentGlobalActions) .concat(matchingNavigateActions), }, { diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index efc8de4345..8f55e9528e 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -2,6 +2,7 @@ import { CommandMenu } from '@/command-menu/components/CommandMenu'; import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads/components/CommandMenuAIChatThreadsPage'; import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage'; import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage'; +import { CommandMenuFrontComponentPage } from '@/command-menu/pages/front-component/components/CommandMenuFrontComponentPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; import { CommandMenuPageLayoutChartSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutChartSettings'; import { CommandMenuPageLayoutGraphFilter } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphFilter'; @@ -64,4 +65,5 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< CommandMenuPages.PageLayoutTabSettings, , ], + [CommandMenuPages.ViewFrontComponent, ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx index 8a3bc5e6cb..956f3079e2 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuActions.tsx @@ -41,6 +41,18 @@ export const useCommandMenuActions = () => { action.scope === ActionScope.Global, ); + const frontComponentGlobalActions: ActionConfig[] = actions?.filter( + (action) => + action.type === ActionType.FrontComponent && + action.scope === ActionScope.Global, + ); + + const frontComponentRecordSelectionActions: ActionConfig[] = actions?.filter( + (action) => + action.type === ActionType.FrontComponent && + action.scope === ActionScope.RecordSelection, + ); + const fallbackActions: ActionConfig[] = actions?.filter( (action) => action.type === ActionType.Fallback, ); @@ -58,6 +70,8 @@ export const useCommandMenuActions = () => { actionObjectActions, workflowRunRecordSelectionActions, workflowRunGlobalActions, + frontComponentGlobalActions, + frontComponentRecordSelectionActions, fallbackActions, createRelatedRecordActions, }; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts b/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts index 1bb6b9f295..f0843102c5 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useMatchingCommandMenuActions.ts @@ -18,6 +18,8 @@ export const useMatchingCommandMenuActions = ({ actionGlobalActions, workflowRunRecordSelectionActions, workflowRunGlobalActions, + frontComponentGlobalActions, + frontComponentRecordSelectionActions, fallbackActions, createRelatedRecordActions, } = useCommandMenuActions(); @@ -41,6 +43,12 @@ export const useMatchingCommandMenuActions = ({ workflowRunGlobalActions, ); + const matchingFrontComponentGlobalActions = + filterActionsWithCommandMenuSearch(frontComponentGlobalActions); + + const matchingFrontComponentRecordSelectionActions = + filterActionsWithCommandMenuSearch(frontComponentRecordSelectionActions); + const matchingCreateRelatedRecordActions = filterActionsWithCommandMenuSearch( createRelatedRecordActions, ); @@ -48,8 +56,10 @@ export const useMatchingCommandMenuActions = ({ const noResults = !matchingStandardActionRecordSelectionActions.length && !matchingWorkflowRunRecordSelectionActions.length && + !matchingFrontComponentRecordSelectionActions.length && !matchingStandardActionGlobalActions.length && !matchingWorkflowRunGlobalActions.length && + !matchingFrontComponentGlobalActions.length && !matchingStandardActionObjectActions.length && !matchingNavigateActions.length && !matchingCreateRelatedRecordActions.length; @@ -59,8 +69,10 @@ export const useMatchingCommandMenuActions = ({ matchingStandardActionRecordSelectionActions, matchingStandardActionObjectActions, matchingWorkflowRunRecordSelectionActions, + matchingFrontComponentRecordSelectionActions, matchingStandardActionGlobalActions, matchingWorkflowRunGlobalActions, + matchingFrontComponentGlobalActions, matchingNavigateActions, matchingCreateRelatedRecordActions, fallbackActions: noResults ? fallbackActions : [], diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useOpenFrontComponentInCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useOpenFrontComponentInCommandMenu.ts new file mode 100644 index 0000000000..13ef505509 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/hooks/useOpenFrontComponentInCommandMenu.ts @@ -0,0 +1,47 @@ +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { useRecoilCallback } from 'recoil'; +import { type IconComponent } from 'twenty-ui/display'; +import { v4 } from 'uuid'; + +export const useOpenFrontComponentInCommandMenu = () => { + const { navigateCommandMenu } = useCommandMenu(); + + const openFrontComponentInCommandMenu = useRecoilCallback( + ({ set }) => + ({ + frontComponentId, + pageTitle, + pageIcon, + resetNavigationStack = false, + }: { + frontComponentId: string; + pageTitle: string; + pageIcon: IconComponent; + resetNavigationStack?: boolean; + }) => { + const pageComponentInstanceId = v4(); + + set( + viewableFrontComponentIdComponentState.atomFamily({ + instanceId: pageComponentInstanceId, + }), + frontComponentId, + ); + + navigateCommandMenu({ + page: CommandMenuPages.ViewFrontComponent, + pageTitle, + pageIcon, + pageId: pageComponentInstanceId, + resetNavigationStack, + }); + }, + [navigateCommandMenu], + ); + + return { + openFrontComponentInCommandMenu, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/front-component/components/CommandMenuFrontComponentPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/front-component/components/CommandMenuFrontComponentPage.tsx new file mode 100644 index 0000000000..f3be7dcda1 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/front-component/components/CommandMenuFrontComponentPage.tsx @@ -0,0 +1,16 @@ +import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState'; +import { FrontComponentRenderer } from '@/front-components/components/FrontComponentRenderer'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const CommandMenuFrontComponentPage = () => { + const viewableFrontComponentId = useRecoilComponentValue( + viewableFrontComponentIdComponentState, + ); + + if (!isDefined(viewableFrontComponentId)) { + return null; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState.ts b/packages/twenty-front/src/modules/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState.ts new file mode 100644 index 0000000000..2cbb88ef3e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState.ts @@ -0,0 +1,10 @@ +import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const viewableFrontComponentIdComponentState = createComponentState< + string | null +>({ + key: 'command-menu/viewable-front-component-id', + defaultValue: null, + componentInstanceContext: CommandMenuPageComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 4f7fd9f684..a8250f1ff6 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -21,4 +21,5 @@ export enum CommandMenuPages { PageLayoutGraphFilter = 'page-layout-graph-filter', PageLayoutIframeSettings = 'page-layout-iframe-settings', PageLayoutTabSettings = 'page-layout-tab-settings', + ViewFrontComponent = 'view-front-component', } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769654418252-add-front-component-id-to-command-menu-item.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769654418252-add-front-component-id-to-command-menu-item.ts new file mode 100644 index 0000000000..df44dcbcce --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769654418252-add-front-component-id-to-command-menu-item.ts @@ -0,0 +1,55 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFrontComponentIdToCommandMenuItem1769654418252 + implements MigrationInterface +{ + name = 'AddFrontComponentIdToCommandMenuItem1769654418252'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD "frontComponentId" uuid`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "workflowVersionId" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_COMMAND_MENU_ITEM_FRONT_COMPONENT_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("frontComponentId", "workspaceId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("workflowVersionId", "workspaceId") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "CHK_command_menu_item_workflow_or_front_component" CHECK (("workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL) OR ("workflowVersionId" IS NULL AND "frontComponentId" IS NOT NULL))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_342086f37d44e726a359ed6fd7d" FOREIGN KEY ("frontComponentId") REFERENCES "core"."frontComponent"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_342086f37d44e726a359ed6fd7d"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "CHK_command_menu_item_workflow_or_front_component"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_FRONT_COMPONENT_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" ALTER COLUMN "workflowVersionId" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("workflowVersionId", "workspaceId")`, + ); + await queryRunner.query( + `ALTER TABLE "core"."commandMenuItem" DROP COLUMN "frontComponentId"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts index bbe4105883..c5b833f60a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.exception.ts @@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception'; export enum CommandMenuItemExceptionCode { COMMAND_MENU_ITEM_NOT_FOUND = 'COMMAND_MENU_ITEM_NOT_FOUND', INVALID_COMMAND_MENU_ITEM_INPUT = 'INVALID_COMMAND_MENU_ITEM_INPUT', + WORKFLOW_OR_FRONT_COMPONENT_REQUIRED = 'WORKFLOW_OR_FRONT_COMPONENT_REQUIRED', } const getCommandMenuItemExceptionUserFriendlyMessage = ( @@ -17,6 +18,8 @@ const getCommandMenuItemExceptionUserFriendlyMessage = ( return msg`Command menu item not found.`; case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT: return msg`Invalid command menu item input.`; + case CommandMenuItemExceptionCode.WORKFLOW_OR_FRONT_COMPONENT_REQUIRED: + return msg`Either workflow version or front component is required.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts index a47a417b8f..82cfd879b8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { CommandMenuItemResolver } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.resolver'; +import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module'; import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor'; import { FlatCommandMenuItemModule } from 'src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module'; @@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceMigrationModule, ApplicationModule, FlatCommandMenuItemModule, + FrontComponentModule, FeatureFlagModule, ], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts index 68c1c00c28..4c508c9108 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts @@ -1,5 +1,14 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; @@ -16,6 +25,8 @@ import { CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-ite import { CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; import { UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor'; +import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; +import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; @UseGuards(WorkspaceAuthGuard, FeatureFlagGuard) @@ -27,8 +38,24 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor export class CommandMenuItemResolver { constructor( private readonly commandMenuItemService: CommandMenuItemService, + private readonly frontComponentService: FrontComponentService, ) {} + @ResolveField(() => FrontComponentDTO, { nullable: true }) + async frontComponent( + @Parent() commandMenuItem: CommandMenuItemDTO, + @AuthWorkspace() workspace: WorkspaceEntity, + ) { + if (!isDefined(commandMenuItem.frontComponentId)) { + return null; + } + + return this.frontComponentService.findById( + commandMenuItem.frontComponentId, + workspace.id, + ); + } + @Query(() => [CommandMenuItemDTO]) @UseGuards(NoPermissionGuard) @RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED) diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts index 12a6e2f11a..f8876e2156 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto.ts @@ -17,6 +17,7 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; registerEnumType(CommandMenuItemAvailabilityType, { name: 'CommandMenuItemAvailabilityType', @@ -30,9 +31,17 @@ export class CommandMenuItemDTO { id: string; @IsUUID() - @IsNotEmpty() - @Field(() => UUIDScalarType) - workflowVersionId: string; + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + workflowVersionId?: string; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + frontComponentId?: string; + + @Field(() => FrontComponentDTO, { nullable: true }) + frontComponent?: FrontComponentDTO | null; @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts index e24926d0ef..cb68310716 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input.ts @@ -15,9 +15,14 @@ import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/com @InputType() export class CreateCommandMenuItemInput { @IsUUID() - @IsNotEmpty() - @Field(() => UUIDScalarType) - workflowVersionId: string; + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + workflowVersionId?: string; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + frontComponentId?: string; @IsString() @IsNotEmpty() 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 44c26064b6..daeabc2acc 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 @@ -1,4 +1,5 @@ import { + Check, Column, CreateDateColumn, Entity, @@ -10,6 +11,7 @@ import { UpdateDateColumn, } from 'typeorm'; +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 { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @@ -24,9 +26,17 @@ export enum CommandMenuItemAvailabilityType { 'workflowVersionId', 'workspaceId', ]) +@Index('IDX_COMMAND_MENU_ITEM_FRONT_COMPONENT_ID_WORKSPACE_ID', [ + 'frontComponentId', + 'workspaceId', +]) @Index('IDX_COMMAND_MENU_ITEM_AVAILABILITY_OBJECT_METADATA_ID', [ 'availabilityObjectMetadataId', ]) +@Check( + 'CHK_command_menu_item_workflow_or_front_component', + '("workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL) OR ("workflowVersionId" IS NULL AND "frontComponentId" IS NOT NULL)', +) export class CommandMenuItemEntity extends SyncableEntity implements Required @@ -34,8 +44,18 @@ export class CommandMenuItemEntity @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: false, type: 'uuid' }) - workflowVersionId: string; + @Column({ nullable: true, type: 'uuid' }) + workflowVersionId: string | null; + + @Column({ nullable: true, type: 'uuid' }) + frontComponentId: string | null; + + @ManyToOne(() => FrontComponentEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'frontComponentId' }) + frontComponent: Relation | null; @Column({ nullable: false }) label: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts index 764e399054..6e6b55c458 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util.ts @@ -15,6 +15,7 @@ export const commandMenuItemGraphqlApiExceptionHandler = (error: Error) => { case CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND: throw new NotFoundError(error); case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT: + case CommandMenuItemExceptionCode.WORKFLOW_OR_FRONT_COMPONENT_REQUIRED: throw new UserInputError(error); default: { return assertUnreachable(error.code); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts index 930bf58799..01e2b6d272 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module.ts @@ -5,6 +5,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; import { WorkspaceFlatCommandMenuItemMapCacheService } from 'src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +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'; @Module({ @@ -13,6 +14,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat CommandMenuItemEntity, ApplicationEntity, ObjectMetadataEntity, + FrontComponentEntity, ]), WorkspaceManyOrAllFlatEntityMapsCacheModule, ], diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts index 0046d1e018..81a08759cb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service.ts @@ -10,6 +10,7 @@ import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu- import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type'; import { fromCommandMenuItemEntityToFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +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 { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util'; @@ -25,13 +26,15 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP private readonly applicationRepository: Repository, @InjectRepository(ObjectMetadataEntity) private readonly objectMetadataRepository: Repository, + @InjectRepository(FrontComponentEntity) + private readonly frontComponentRepository: Repository, ) { super(); } async computeForCache(workspaceId: string): Promise { - const [commandMenuItems, applications, objectMetadatas] = await Promise.all( - [ + const [commandMenuItems, applications, objectMetadatas, frontComponents] = + await Promise.all([ this.commandMenuItemRepository.find({ where: { workspaceId }, withDeleted: true, @@ -46,13 +49,19 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP select: ['id', 'universalIdentifier'], withDeleted: true, }), - ], - ); + this.frontComponentRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + ]); const applicationIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(applications); const objectMetadataIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(objectMetadatas); + const frontComponentIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(frontComponents); const flatCommandMenuItemMaps = createEmptyFlatEntityMaps(); @@ -62,6 +71,7 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP entity: commandMenuItemEntity, applicationIdToUniversalIdentifierMap, objectMetadataIdToUniversalIdentifierMap, + frontComponentIdToUniversalIdentifierMap, }); addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts index a304fcf1d8..c2de36fa1d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util.ts @@ -11,6 +11,7 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({ entity: commandMenuItemEntity, applicationIdToUniversalIdentifierMap, objectMetadataIdToUniversalIdentifierMap, + frontComponentIdToUniversalIdentifierMap, }: FromEntityToFlatEntityArgs<'commandMenuItem'>): FlatCommandMenuItem => { const applicationUniversalIdentifier = applicationIdToUniversalIdentifierMap.get( @@ -40,9 +41,26 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({ } } + let frontComponentUniversalIdentifier: string | null = null; + + if (isDefined(commandMenuItemEntity.frontComponentId)) { + frontComponentUniversalIdentifier = + frontComponentIdToUniversalIdentifierMap.get( + commandMenuItemEntity.frontComponentId, + ) ?? null; + + if (!isDefined(frontComponentUniversalIdentifier)) { + throw new FlatEntityMapsException( + `FrontComponent with id ${commandMenuItemEntity.frontComponentId} not found for commandMenuItem ${commandMenuItemEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + } + return { id: commandMenuItemEntity.id, workflowVersionId: commandMenuItemEntity.workflowVersionId, + frontComponentId: commandMenuItemEntity.frontComponentId, label: commandMenuItemEntity.label, icon: commandMenuItemEntity.icon, isPinned: commandMenuItemEntity.isPinned, @@ -58,6 +76,7 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({ universalIdentifier: commandMenuItemEntity.universalIdentifier, applicationUniversalIdentifier, availabilityObjectMetadataUniversalIdentifier, + frontComponentUniversalIdentifier, }, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts index e3bd03c926..0b27d55e82 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util.ts @@ -1,5 +1,10 @@ +import { isDefined } from 'twenty-shared/utils'; import { v4 as uuidv4 } from 'uuid'; +import { + CommandMenuItemException, + CommandMenuItemExceptionCode, +} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; @@ -13,13 +18,28 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({ workspaceId: string; applicationId: string; }): FlatCommandMenuItem => { + const hasWorkflowVersionId = isDefined( + createCommandMenuItemInput.workflowVersionId, + ); + const hasFrontComponentId = isDefined( + createCommandMenuItemInput.frontComponentId, + ); + + if (hasWorkflowVersionId === hasFrontComponentId) { + throw new CommandMenuItemException( + 'Exactly one of workflowVersionId or frontComponentId is required', + CommandMenuItemExceptionCode.WORKFLOW_OR_FRONT_COMPONENT_REQUIRED, + ); + } + const id = uuidv4(); const now = new Date().toISOString(); return { id, universalIdentifier: id, - workflowVersionId: createCommandMenuItemInput.workflowVersionId, + workflowVersionId: createCommandMenuItemInput.workflowVersionId ?? null, + frontComponentId: createCommandMenuItemInput.frontComponentId ?? null, label: createCommandMenuItemInput.label, icon: createCommandMenuItemInput.icon ?? null, isPinned: createCommandMenuItemInput.isPinned ?? false, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts index c24a31e5f7..865631b11c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util.ts @@ -5,7 +5,8 @@ export const fromFlatCommandMenuItemToCommandMenuItemDto = ( flatCommandMenuItem: FlatCommandMenuItem, ): CommandMenuItemDTO => ({ id: flatCommandMenuItem.id, - workflowVersionId: flatCommandMenuItem.workflowVersionId, + workflowVersionId: flatCommandMenuItem.workflowVersionId ?? undefined, + frontComponentId: flatCommandMenuItem.frontComponentId ?? undefined, label: flatCommandMenuItem.label, icon: flatCommandMenuItem.icon ?? undefined, isPinned: flatCommandMenuItem.isPinned, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts index 8c1947f601..cecb49a443 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts @@ -85,6 +85,11 @@ export const ALL_METADATA_RELATIONS = { flatEntityForeignKeyAggregator: null, foreignKey: 'availabilityObjectMetadataId', }, + frontComponent: { + metadataName: 'frontComponent', + flatEntityForeignKeyAggregator: null, + foreignKey: 'frontComponentId', + }, }, oneToMany: {}, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 0015d12f8d..99015f696a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -55,6 +55,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { skill: {}, commandMenuItem: { objectMetadata: true, + frontComponent: true, }, navigationMenuItem: { objectMetadata: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 4f5ab220a3..b1b8636593 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -1,10 +1,11 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`; exports[`getMetadataRelatedMetadataNames should return related metadata names for commandMenuItem 1`] = ` [ "objectMetadata", + "frontComponent", ] `; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index fd80c8f27f..d763efc6a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -5,9 +5,9 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with "rowLevelPermissionPredicate", "navigationMenuItem", "viewFilter", + "commandMenuItem", "pageLayoutWidget", "viewField", - "commandMenuItem", "index", "pageLayout", "roleTarget", diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/front-component-and-command-menu-item-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/front-component-and-command-menu-item-seeds.constant.ts new file mode 100644 index 0000000000..8a22813ff1 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/front-component-and-command-menu-item-seeds.constant.ts @@ -0,0 +1,7 @@ +export const FRONT_COMPONENT_SEEDS = { + DEMO_APP: 'DEMO_APP', +} as const; + +export const COMMAND_MENU_ITEM_SEEDS = { + DEMO_FRONT_COMPONENT: 'DEMO_FRONT_COMPONENT', +} as const; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts new file mode 100644 index 0000000000..a6b90c82db --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts @@ -0,0 +1,76 @@ +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { + COMMAND_MENU_ITEM_SEEDS, + FRONT_COMPONENT_SEEDS, +} from 'src/engine/workspace-manager/dev-seeder/core/constants/front-component-and-command-menu-item-seeds.constant'; +import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util'; + +type FrontComponentSeed = { + id: string; + name: string; + workspaceId: string; + universalIdentifier: string; + applicationId: string; +}; + +type CommandMenuItemSeed = { + id: string; + workspaceId: string; + universalIdentifier: string; + applicationId: string; + workflowVersionId: null; + frontComponentId: string; + label: string; + icon: string | null; + isPinned: boolean; + availabilityType: CommandMenuItemAvailabilityType; + availabilityObjectMetadataId: null; +}; + +type FrontComponentAndCommandMenuItemSeeds = { + frontComponents: FrontComponentSeed[]; + commandMenuItems: CommandMenuItemSeed[]; +}; + +export const getFrontComponentAndCommandMenuItemDataSeeds = ( + workspaceId: string, + applicationId: string, +): FrontComponentAndCommandMenuItemSeeds => { + const frontComponentId = generateSeedId( + workspaceId, + FRONT_COMPONENT_SEEDS.DEMO_APP, + ); + + const frontComponents: FrontComponentSeed[] = [ + { + id: frontComponentId, + name: 'Demo App', + workspaceId, + universalIdentifier: frontComponentId, + applicationId, + }, + ]; + + const commandMenuItemId = generateSeedId( + workspaceId, + COMMAND_MENU_ITEM_SEEDS.DEMO_FRONT_COMPONENT, + ); + + const commandMenuItems: CommandMenuItemSeed[] = [ + { + id: commandMenuItemId, + workspaceId, + universalIdentifier: commandMenuItemId, + applicationId, + workflowVersionId: null, + frontComponentId, + label: 'Open Demo App', + icon: 'IconApps', + isPinned: false, + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + availabilityObjectMetadataId: null, + }, + ]; + + return { frontComponents, commandMenuItems }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index bd74d95dc1..21ec0bfc2e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -91,6 +91,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts new file mode 100644 index 0000000000..bad59d67d4 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts @@ -0,0 +1,80 @@ +import { type DataSource } from 'typeorm'; + +import { getFrontComponentAndCommandMenuItemDataSeeds } from 'src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util'; + +type SeedFrontComponentsAndCommandMenuItemsArgs = { + dataSource: DataSource; + schemaName: string; + workspaceId: string; + applicationId: string; +}; + +export const seedFrontComponentsAndCommandMenuItems = async ({ + dataSource, + schemaName, + workspaceId, + applicationId, +}: SeedFrontComponentsAndCommandMenuItemsArgs) => { + const { frontComponents, commandMenuItems } = + getFrontComponentAndCommandMenuItemDataSeeds(workspaceId, applicationId); + + if (frontComponents.length > 0) { + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.frontComponent`, [ + 'id', + 'name', + 'workspaceId', + 'universalIdentifier', + 'applicationId', + ]) + .values( + frontComponents.map((row) => ({ + id: row.id, + name: row.name, + workspaceId: row.workspaceId, + universalIdentifier: row.universalIdentifier, + applicationId: row.applicationId, + })), + ) + .orIgnore() + .execute(); + } + + if (commandMenuItems.length > 0) { + await dataSource + .createQueryBuilder() + .insert() + .into(`${schemaName}.commandMenuItem`, [ + 'id', + 'workspaceId', + 'universalIdentifier', + 'applicationId', + 'workflowVersionId', + 'frontComponentId', + 'label', + 'icon', + 'isPinned', + 'availabilityType', + 'availabilityObjectMetadataId', + ]) + .values( + commandMenuItems.map((row) => ({ + id: row.id, + workspaceId: row.workspaceId, + universalIdentifier: row.universalIdentifier, + applicationId: row.applicationId, + workflowVersionId: row.workflowVersionId, + frontComponentId: row.frontComponentId, + label: row.label, + icon: row.icon, + isPinned: row.isPinned, + availabilityType: row.availabilityType, + availabilityObjectMetadataId: row.availabilityObjectMetadataId, + })), + ) + .orIgnore() + .execute(); + } +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index 436e495151..69784297ab 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { DataSource } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; @@ -17,6 +17,7 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work import { SeededWorkspacesIds } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service'; import { seedCoreSchema } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util'; +import { seedFrontComponentsAndCommandMenuItems } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util'; import { seedPageLayoutTabs } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layout-tabs.util'; import { seedPageLayoutWidgets } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layout-widgets.util'; import { seedPageLayouts } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layouts.util'; @@ -145,6 +146,23 @@ export class DevSeederService { featureFlags: featureFlagsMap, }); + await seedFrontComponentsAndCommandMenuItems({ + dataSource: this.coreDataSource, + schemaName: 'core', + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + }); + + const relatedCommandMenuItemAndFrontComponentCacheKeysToInvalidate = [ + ...getMetadataRelatedMetadataNames(ALL_METADATA_NAME.commandMenuItem), + ...getMetadataRelatedMetadataNames(ALL_METADATA_NAME.frontComponent), + ].map(getMetadataFlatEntityMapsKey); + + await this.workspaceCacheService.invalidateAndRecompute( + workspaceId, + relatedCommandMenuItemAndFrontComponentCacheKeysToInvalidate, + ); + await this.workspaceCacheStorageService.flush(workspaceId, undefined); } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index e25404d15d..514cd83aba 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -19,6 +19,7 @@ import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engi import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationIndexActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/workspace-migration-index-actions-builder.service'; +import { WorkspaceMigrationLogicFunctionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/workspace-migration-logic-function-actions-builder.service'; import { WorkspaceMigrationNavigationMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service'; import { WorkspaceMigrationObjectActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/workspace-migration-object-actions-builder.service'; import { WorkspaceMigrationPageLayoutTabActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout-tab/workspace-migration-page-layout-tab-actions-builder.service'; @@ -28,7 +29,6 @@ import { WorkspaceMigrationRoleTargetActionsBuilderService } from 'src/engine/wo import { WorkspaceMigrationRoleActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/role/workspace-migration-role-actions-builder.service'; import { WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/row-level-permission-predicate-group/workspace-migration-row-level-permission-predicate-group-actions-builder.service'; import { WorkspaceMigrationRowLevelPermissionPredicateActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/row-level-permission-predicate/workspace-migration-row-level-permission-predicate-actions-builder.service'; -import { WorkspaceMigrationLogicFunctionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/logic-function/workspace-migration-logic-function-actions-builder.service'; import { WorkspaceMigrationSkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/skill/workspace-migration-skill-actions-builder.service'; import { WorkspaceMigrationViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/workspace-migration-view-field-actions-builder.service'; import { WorkspaceMigrationViewFilterGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-filter-group/workspace-migration-view-filter-group-actions-builder.service'; @@ -721,6 +721,8 @@ export class WorkspaceMigrationBuildOrchestratorService { dependencyOptimisticFlatEntityMaps: { flatObjectMetadataMaps: optimisticAllFlatEntityMaps.flatObjectMetadataMaps, + flatFrontComponentMaps: + optimisticAllFlatEntityMaps.flatFrontComponentMaps, }, workspaceId, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts index e7b4c041eb..38dfb78ad2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service.ts @@ -37,6 +37,19 @@ export class FlatCommandMenuItemValidatorService { }); } + const hasWorkflowVersionId = isDefined( + flatCommandMenuItem.workflowVersionId, + ); + const hasFrontComponentId = isDefined(flatCommandMenuItem.frontComponentId); + + if (hasWorkflowVersionId === hasFrontComponentId) { + validationResult.errors.push({ + code: CommandMenuItemExceptionCode.WORKFLOW_OR_FRONT_COMPONENT_REQUIRED, + message: t`Exactly one of workflowVersionId or frontComponentId is required`, + userFriendlyMessage: msg`Exactly one of workflow version or front component is required`, + }); + } + return validationResult; } diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap index f689bd51a6..6cae0eb7b2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/__snapshots__/failing-command-menu-item-creation.integration-spec.ts.snap @@ -54,13 +54,11 @@ exports[`CommandMenuItem creation should fail when creating with missing workflo { "extensions": { "code": "BAD_USER_INPUT", - "http": { - "status": 400, - }, - "userFriendlyMessage": "An error occurred.", + "subCode": "WORKFLOW_OR_FRONT_COMPONENT_REQUIRED", + "userFriendlyMessage": "Either workflow version or front component is required.", }, - "message": "Field "workflowVersionId" of required type "UUID!" was not provided.", - "name": "GraphQLError", + "message": "Exactly one of workflowVersionId or frontComponentId is required", + "name": "UserInputError", } `; @@ -93,3 +91,15 @@ exports[`CommandMenuItem creation should fail when creating with invalid workflo "name": "ValidationError", } `; + +exports[`CommandMenuItem creation should fail when creating with both workflowVersionId and frontComponentId 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "WORKFLOW_OR_FRONT_COMPONENT_REQUIRED", + "userFriendlyMessage": "Either workflow version or front component is required.", + }, + "message": "Exactly one of workflowVersionId or frontComponentId is required", + "name": "UserInputError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts index e3387bda85..0ce7fbd334 100644 --- a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/failing-command-menu-item-creation.integration-spec.ts @@ -59,6 +59,16 @@ const failingCommandMenuItemCreationTestCases: EachTestingContext[] }, }, }, + { + title: 'when creating with both workflowVersionId and frontComponentId', + context: { + input: { + workflowVersionId: faker.string.uuid(), + frontComponentId: faker.string.uuid(), + label: 'Test Label', + }, + }, + }, ]; describe('CommandMenuItem creation should fail', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts index 1379bdc139..1d1728a56b 100644 --- a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts @@ -1,6 +1,8 @@ import { faker } from '@faker-js/faker'; import { createCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item.util'; import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; @@ -10,6 +12,7 @@ import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/com describe('CommandMenuItem creation should succeed', () => { let createdCommandMenuItemId: string; + let createdFrontComponentId: string | undefined; let companyObjectMetadataId: string; let personObjectMetadataId: string; @@ -64,6 +67,13 @@ describe('CommandMenuItem creation should succeed', () => { }); createdCommandMenuItemId = undefined as unknown as string; } + if (createdFrontComponentId) { + await deleteFrontComponent({ + expectToFail: false, + input: { id: createdFrontComponentId }, + }); + createdFrontComponentId = undefined; + } }); it('should create a basic command menu item with minimal input', async () => { @@ -162,4 +172,34 @@ describe('CommandMenuItem creation should succeed', () => { availabilityType: CommandMenuItemAvailabilityType.GLOBAL, }); }); + + it('should create command menu item with frontComponentId', async () => { + const { data: frontComponentData } = await createFrontComponent({ + expectToFail: false, + input: { name: 'Test Front Component' }, + }); + + createdFrontComponentId = frontComponentData?.createFrontComponent?.id; + jestExpectToBeDefined(createdFrontComponentId); + + const { data } = await createCommandMenuItem({ + expectToFail: false, + input: { + frontComponentId: createdFrontComponentId, + label: 'Front Component Command', + }, + }); + + createdCommandMenuItemId = data?.createCommandMenuItem?.id; + + expect(data.createCommandMenuItem).toMatchObject({ + id: expect.any(String), + frontComponentId: createdFrontComponentId, + label: 'Front Component Command', + frontComponent: { + id: createdFrontComponentId, + name: 'Test Front Component', + }, + }); + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts index 0cd3b54585..77034f3638 100644 --- a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/utils/create-command-menu-item-query-factory.util.ts @@ -8,6 +8,11 @@ export type CreateCommandMenuItemFactoryInput = CreateCommandMenuItemInput; const DEFAULT_COMMAND_MENU_ITEM_GQL_FIELDS = ` id workflowVersionId + frontComponentId + frontComponent { + id + name + } label icon isPinned