From abdab2fb7eb48f5907165f97ed74867f760d42f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:25:18 +0100 Subject: [PATCH] [Command menu items] Create engine commands (#18681) ## Description - Introduces a new engine command execution model that replaces the previous approach of mapping `EngineComponentKey` to React components. Instead, engine commands are now mounted headlessly via `HeadlessEngineCommandMountRoot`, with their execution context populated synchronously before mounting. - Creates new headless command components - Moves error handling from the SDK layer to the host app by wrapping all mounted commands with a new `CommandMenuItemErrorBoundary` The new flow works as follows: - When a command menu item with an `engineComponentKey` is clicked, `useCommandMenuItemFrontComponentCommands` calls `useMountEngineCommand`, which synchronously reads the current context store (object metadata, selected records, filters, view ID, etc.) and writes a `MountedEngineCommandContext` into `mountedEngineCommandsState`. - The command is then mounted into `mountedEngineCommandsState`, which triggers `HeadlessEngineCommandMountRoot` to render the corresponding headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`, wrapped in `CommandMenuItemErrorBoundary`, `ContextStoreComponentInstanceContext.Provider`, and `EngineCommandComponentInstanceContext.Provider`. - Each command component reads its execution context and delegates to one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect` (simple actions), `HeadlessConfirmationModalEngineCommandEffect` (destructive actions needing confirmation), `HeadlessNavigateEngineCommand` (GO_TO_* commands), or `HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI, VIEW_PREVIOUS_AI_CHATS). - After execution, the command self-unmounts via `useUnmountEngineCommand`, which removes the entry from `mountedEngineCommandsState` and stops rendering the component. --- .../app/components/AppRouterProviders.tsx | 10 +- .../CommandMenuConfirmationModalManager.tsx | 9 +- .../hooks/useCommandMenuConfirmationModal.ts | 2 +- .../commandMenuItemConfirmationModalState.ts | 3 +- ...nfirmationModalResultBrowserEventDetail.ts | 4 +- .../components/CommandMenuItemComponent.tsx | 2 +- .../CommandMenuItemErrorBoundary.tsx | 49 ++++ ...ssConfirmationModalEngineCommandEffect.tsx | 95 +++++++ .../HeadlessEngineCommandMountRoot.tsx | 41 +++ .../HeadlessEngineCommandWrapperEffect.tsx | 50 ++++ .../HeadlessNavigateEngineCommand.tsx | 24 ++ ...HeadlessOpenSidePanelPageEngineCommand.tsx | 37 +++ ...EngineComponentKeyHeadlessComponentMap.tsx | 254 ++++++++++++++++++ ...sHeadlessEngineCommandEffectInitialized.ts | 12 + .../hooks/useMountEngineCommand.ts | 129 +++++++++ .../hooks/useMountedEngineCommandContext.ts | 22 ++ .../hooks/useUnmountEngineCommand.ts | 23 ++ .../DeleteMultipleRecordsCommand.tsx | 42 +++ .../DestroyMultipleRecordsCommand.tsx | 61 +++++ .../ExportMultipleRecordsCommand.tsx | 24 ++ .../MergeMultipleRecordsCommand.tsx | 29 ++ .../RestoreMultipleRecordsCommand.tsx | 71 +++++ .../UpdateMultipleRecordsCommand.tsx | 18 ++ ...NewIndexRecordNoSelectionRecordCommand.tsx | 26 ++ .../CreateNewViewNoSelectionRecordCommand.tsx | 39 +++ ...DeletedRecordsNoSelectionRecordCommand.tsx | 49 ++++ .../ImportRecordsNoSelectionRecordCommand.tsx | 23 ++ ...DeletedRecordsNoSelectionRecordCommand.tsx | 31 +++ .../AddToFavoritesSingleRecordCommand.tsx | 27 ++ .../components/DeleteSingleRecordCommand.tsx | 59 ++++ .../components/DestroySingleRecordCommand.tsx | 56 ++++ .../ExportNoteSingleRecordCommand.tsx | 44 +++ .../components/ExportSingleRecordCommand.tsx | 30 +++ ...avigateToNextRecordSingleRecordCommand.tsx | 24 ++ ...ateToPreviousRecordSingleRecordCommand.tsx | 26 ++ ...RemoveFromFavoritesSingleRecordCommand.tsx | 42 +++ .../components/RestoreSingleRecordCommand.tsx | 51 ++++ .../CancelDashboardSingleRecordCommand.tsx | 34 +++ .../DuplicateDashboardSingleRecordCommand.tsx | 44 +++ .../EditDashboardSingleRecordCommand.tsx | 28 ++ .../SaveDashboardSingleRecordCommand.tsx | 35 +++ ...celRecordPageLayoutSingleRecordCommand.tsx | 38 +++ ...ditRecordPageLayoutSingleRecordCommand.tsx | 32 +++ ...aveRecordPageLayoutSingleRecordCommand.tsx | 44 +++ ...eVersionWorkflowRunSingleRecordCommand.tsx | 26 ++ ...WorkflowWorkflowRunSingleRecordCommand.tsx | 23 ++ .../StopWorkflowRunSingleRecordCommand.tsx | 37 +++ ...RunsWorkflowVersionSingleRecordCommand.tsx | 59 ++++ ...ionsWorkflowVersionSingleRecordCommand.tsx | 47 ++++ ...flowWorkflowVersionSingleRecordCommand.tsx | 25 ++ ...raftWorkflowVersionSingleRecordCommand.tsx | 86 ++++++ .../ActivateWorkflowSingleRecordCommand.tsx | 32 +++ .../AddNodeWorkflowSingleRecordCommand.tsx | 30 +++ .../DeactivateWorkflowSingleRecordCommand.tsx | 31 +++ ...iscardDraftWorkflowSingleRecordCommand.tsx | 31 +++ .../DuplicateWorkflowSingleRecordCommand.tsx | 55 ++++ ...tiveVersionWorkflowSingleRecordCommand.tsx | 33 +++ .../SeeRunsWorkflowSingleRecordCommand.tsx | 35 +++ ...SeeVersionsWorkflowSingleRecordCommand.tsx | 35 +++ .../TestWorkflowSingleRecordCommand.tsx | 32 +++ .../TidyUpWorkflowSingleRecordCommand.tsx | 42 +++ .../EngineCommandComponentInstanceContext.ts | 4 + .../states/mountedEngineCommandsState.ts | 9 + .../types/MountedEngineCommandContext.ts | 19 ++ ...dMenuContextProviderServerItemsContent.tsx | 9 +- ...tsx => useCommandMenuItemsFromBackend.tsx} | 27 +- ...seContextStoreObjectMetadataItemOrThrow.ts | 2 +- .../HeadlessFrontComponentMountRoot.tsx | 51 ++-- .../useFrontComponentExecutionContext.ts | 10 +- .../hooks/useRemoveRecordFilter.ts | 3 +- .../hooks/useUpsertRecordFilter.ts | 3 +- .../hooks/useRecordIndexLazyFetchRecords.ts | 2 + .../useFindManyRecordIndexTableParams.ts | 5 + .../hooks/useHandleToggleTrashColumnFilter.ts | 4 +- .../hooks/useBuildRecordInputFromFilters.ts | 3 + .../hooks/useCreateNewIndexRecord.ts | 6 + .../components/FrontComponentWorkerEffect.tsx | 10 +- .../twenty-sdk/src/sdk/command/Command.tsx | 19 +- .../src/sdk/command/CommandLink.tsx | 19 +- .../src/sdk/command/CommandModal.tsx | 36 +-- .../sdk/command/CommandOpenSidePanelPage.tsx | 29 +- .../src/types/ConfirmationModalCaller.ts | 3 + packages/twenty-shared/src/types/index.ts | 1 + 83 files changed, 2594 insertions(+), 132 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemErrorBoundary.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandMountRoot.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountEngineCommand.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountedEngineCommandContext.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useUnmountEngineCommand.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/CancelRecordPageLayoutSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/states/mountedEngineCommandsState.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/types/MountedEngineCommandContext.ts rename packages/twenty-front/src/modules/command-menu-item/server-items/hooks/{useCommandMenuItemFrontComponentCommands.tsx => useCommandMenuItemsFromBackend.tsx} (90%) create mode 100644 packages/twenty-shared/src/types/ConfirmationModalCaller.ts diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 082c3a1a8b..34fded19dc 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -1,12 +1,10 @@ import { AgentChatProvider } from '@/ai/components/AgentChatProvider'; -import { CommandMenuConfirmationModalManager } from '@/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; +import { CommandMenuConfirmationModalManager } from '@/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager'; import { MinimalMetadataGater } from '@/metadata-store/components/MinimalMetadataGater'; import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components/IsMinimalMetadataReadyEffect'; import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider'; -import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect'; -import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect'; import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect'; import { AuthProvider } from '@/auth/components/AuthProvider'; import { CaptchaProvider } from '@/captcha/components/CaptchaProvider'; @@ -17,10 +15,13 @@ import { ClientConfigProviderEffect } from '@/client-config/components/ClientCon import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider'; import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect'; import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect'; -import { HeadlessFrontComponentMountRoot } from '@/front-components/components/HeadlessFrontComponentMountRoot'; +import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect'; +import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect'; import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider'; import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider'; +import { HeadlessEngineCommandMountRoot } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandMountRoot'; +import { HeadlessFrontComponentMountRoot } from '@/front-components/components/HeadlessFrontComponentMountRoot'; import { SSEProvider } from '@/sse-db-event/components/SSEProvider'; import { SupportChatEffect } from '@/support/components/SupportChatEffect'; import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager'; @@ -74,6 +75,7 @@ export const AppRouterProviders = () => { + diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx index f562e182ef..a926a22205 100644 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/components/CommandMenuConfirmationModalManager.tsx @@ -1,5 +1,5 @@ -import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName'; import { COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalId'; +import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName'; import { commandMenuItemConfirmationModalConfigState } from '@/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState'; import { type CommandMenuConfirmationModalResult, @@ -10,6 +10,7 @@ import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpe import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { isDefined } from 'twenty-shared/utils'; export const CommandMenuConfirmationModalManager = () => { const commandMenuItemConfirmationModalConfig = useAtomStateValue( @@ -23,12 +24,12 @@ export const CommandMenuConfirmationModalManager = () => { commandMenuItemConfirmationModalConfigState, ); - const callerId = commandMenuItemConfirmationModalConfig?.frontComponentId; + const caller = commandMenuItemConfirmationModalConfig?.caller; const emitConfirmationResult = ( confirmationResult: CommandMenuConfirmationModalResult, ) => { - if (!callerId) { + if (!isDefined(caller)) { return; } @@ -37,7 +38,7 @@ export const CommandMenuConfirmationModalManager = () => { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, { detail: { - frontComponentId: callerId, + caller, confirmationResult, }, }, diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal.ts b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal.ts index 11a56bb921..bc4ecbcd14 100644 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal.ts +++ b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal.ts @@ -33,7 +33,7 @@ export const useCommandMenuConfirmationModal = () => { isCommandMenuItemConfirmationModalOpened ) { throw new Error( - 'Command menu item confirmation modal is already active for another front component', + 'Command menu item confirmation modal is already active for another caller', ); } diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState.ts b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState.ts index b6f3448810..3a77b03023 100644 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState.ts +++ b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState.ts @@ -1,10 +1,11 @@ import { type ReactNode } from 'react'; +import { type ConfirmationModalCaller } from 'twenty-shared/types'; import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; import { type ButtonAccent } from 'twenty-ui/input'; export type CommandMenuItemConfirmationModalConfig = { - frontComponentId: string; + caller: ConfirmationModalCaller; title: string; subtitle: ReactNode; confirmButtonText?: string; diff --git a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts index e5030c7ae1..41bfe70b47 100644 --- a/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts +++ b/packages/twenty-front/src/modules/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail.ts @@ -1,6 +1,8 @@ +import { type ConfirmationModalCaller } from 'twenty-shared/types'; + export type CommandMenuConfirmationModalResult = 'confirm' | 'cancel'; export type CommandMenuConfirmationModalResultBrowserEventDetail = { - frontComponentId: string; + caller: ConfirmationModalCaller; confirmationResult: CommandMenuConfirmationModalResult; }; diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemComponent.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemComponent.tsx index 0b9bc213ad..ecc70d584b 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemComponent.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemComponent.tsx @@ -1,5 +1,5 @@ -import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig'; import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext'; +import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig'; export const CommandMenuItemComponent = ({ action, diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemErrorBoundary.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemErrorBoundary.tsx new file mode 100644 index 0000000000..4b3e7c2809 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemErrorBoundary.tsx @@ -0,0 +1,49 @@ +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { type ReactNode } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +type CommandMenuItemErrorBoundaryProps = { + children: ReactNode; + engineCommandId: string; + shouldReportToSentry?: boolean; + onError?: () => void; +}; + +export const CommandMenuItemErrorBoundary = ({ + children, + engineCommandId, + shouldReportToSentry = false, + onError, +}: CommandMenuItemErrorBoundaryProps) => { + const { enqueueErrorSnackBar } = useSnackBar(); + + const handleError = async (error: Error) => { + enqueueErrorSnackBar({ message: error.message }); + + if (shouldReportToSentry) { + try { + const { captureException } = await import('@sentry/react'); + captureException(error, (scope) => { + scope.setTag('component', 'CommandMenuItem'); + + return scope; + }); + } catch { + // oxlint-disable-next-line no-console + console.error('Failed to capture exception with Sentry:', error); + } + } + + onError?.(); + }; + + return ( + null} + onError={handleError} + resetKeys={[engineCommandId]} + > + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx new file mode 100644 index 0000000000..a069c3b1e5 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect.tsx @@ -0,0 +1,95 @@ +import { useIsHeadlessEngineCommandEffectInitialized } from '@/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized'; +import { type ReactNode, useEffect } from 'react'; + +import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName'; +import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal'; +import { type CommandMenuConfirmationModalResultBrowserEventDetail } from '@/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail'; +import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand'; +import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { type ButtonAccent } from 'twenty-ui/input'; + +export type HeadlessConfirmationModalEngineCommandEffectProps = { + title: string; + subtitle: ReactNode; + confirmButtonText: string; + confirmButtonAccent?: ButtonAccent; + execute: () => void | Promise; +}; + +export const HeadlessConfirmationModalEngineCommandEffect = ({ + title, + subtitle, + confirmButtonText, + confirmButtonAccent = 'danger', + execute, +}: HeadlessConfirmationModalEngineCommandEffectProps) => { + const { isInitializedRef, setIsInitialized } = + useIsHeadlessEngineCommandEffectInitialized(); + const engineCommandId = useAvailableComponentInstanceIdOrThrow( + EngineCommandComponentInstanceContext, + ); + const unmountEngineCommand = useUnmountEngineCommand(); + const { openConfirmationModal } = useCommandMenuConfirmationModal(); + + useEffect(() => { + if (isInitializedRef.current) { + return; + } + + setIsInitialized(true); + + openConfirmationModal({ + caller: { type: 'engineCommand', engineCommandId }, + title, + subtitle, + confirmButtonText, + confirmButtonAccent, + }); + }, [ + isInitializedRef, + setIsInitialized, + engineCommandId, + openConfirmationModal, + title, + subtitle, + confirmButtonText, + confirmButtonAccent, + ]); + + useEffect(() => { + const handleConfirmationResult = async (event: Event) => { + const customEvent = + event as CustomEvent; + + const caller = customEvent.detail.caller; + + if ( + caller.type !== 'engineCommand' || + caller.engineCommandId !== engineCommandId + ) { + return; + } + + if (customEvent.detail.confirmationResult === 'confirm') { + await execute(); + } + + unmountEngineCommand(engineCommandId); + }; + + window.addEventListener( + COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, + handleConfirmationResult, + ); + + return () => { + window.removeEventListener( + COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, + handleConfirmationResult, + ); + }; + }, [engineCommandId, execute, unmountEngineCommand]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandMountRoot.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandMountRoot.tsx new file mode 100644 index 0000000000..d5983f842f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandMountRoot.tsx @@ -0,0 +1,41 @@ +import { CommandMenuItemErrorBoundary } from '@/command-menu-item/display/components/CommandMenuItemErrorBoundary'; +import { ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP } from '@/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap'; +import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand'; +import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext'; +import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState'; +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +export const HeadlessEngineCommandMountRoot = () => { + const mountedEngineCommands = useAtomStateValue(mountedEngineCommandsState); + const unmountEngineCommand = useUnmountEngineCommand(); + + return ( + <> + {[...mountedEngineCommands.entries()].map( + ([engineCommandId, mountContext]) => ( + unmountEngineCommand(engineCommandId)} + > + + + { + ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP[ + mountContext.engineComponentKey + ] + } + + + + ), + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect.tsx new file mode 100644 index 0000000000..4ebe7f75bc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect.tsx @@ -0,0 +1,50 @@ +import { useIsHeadlessEngineCommandEffectInitialized } from '@/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized'; +import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand'; +import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useEffect } from 'react'; + +export type HeadlessEngineCommandWrapperEffectProps = { + execute: () => void | Promise; +}; + +export const HeadlessEngineCommandWrapperEffect = ({ + execute, +}: HeadlessEngineCommandWrapperEffectProps) => { + const { isInitializedRef, setIsInitialized } = + useIsHeadlessEngineCommandEffectInitialized(); + + const engineCommandId = useAvailableComponentInstanceIdOrThrow( + EngineCommandComponentInstanceContext, + ); + + const unmountEngineCommand = useUnmountEngineCommand(); + + const { enqueueErrorSnackBar } = useSnackBar(); + + useEffect(() => { + if (isInitializedRef.current) { + return; + } + + setIsInitialized(true); + + const run = async () => { + await execute(); + + unmountEngineCommand(engineCommandId); + }; + + run(); + }, [ + execute, + isInitializedRef, + setIsInitialized, + engineCommandId, + unmountEngineCommand, + enqueueErrorSnackBar, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand.tsx new file mode 100644 index 0000000000..8ffb407e33 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand.tsx @@ -0,0 +1,24 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { type PathParam, useNavigate } from 'react-router-dom'; +import { type AppPath } from 'twenty-shared/types'; +import { getAppPath } from 'twenty-shared/utils'; + +export const HeadlessNavigateEngineCommand = ({ + to, + params, + queryParams, +}: { + to: T; + params?: { [key in PathParam]: string | null }; + queryParams?: Record; +}) => { + const navigate = useNavigate(); + const path = getAppPath(to, params, queryParams); + + // eslint-disable-next-line twenty/no-navigate-prefer-link + const onExecute = () => { + navigate(path); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand.tsx new file mode 100644 index 0000000000..cb7e6485b0 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand.tsx @@ -0,0 +1,37 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { type MessageDescriptor } from '@lingui/core'; +import { t } from '@lingui/core/macro'; +import { type SidePanelPages } from 'twenty-shared/types'; +import { type IconComponent } from 'twenty-ui/display'; + +export const HeadlessOpenSidePanelPageEngineCommand = ({ + page, + pageTitle, + pageIcon, + shouldResetSearchState = false, +}: { + page: SidePanelPages; + pageTitle: MessageDescriptor; + pageIcon: IconComponent; + shouldResetSearchState?: boolean; +}) => { + const { navigateSidePanel } = useNavigateSidePanel(); + const setSidePanelSearch = useSetAtomState(sidePanelSearchState); + + const onExecute = () => { + navigateSidePanel({ + page, + pageTitle: t(pageTitle), + pageIcon, + }); + + if (shouldResetSearchState) { + setSidePanelSearch(''); + } + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx new file mode 100644 index 0000000000..e50cd6b420 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx @@ -0,0 +1,254 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand'; +import { DeleteMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand'; +import { DestroyMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand'; +import { ExportMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand'; +import { MergeMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand'; +import { RestoreMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand'; +import { UpdateMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand'; +import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand'; +import { CreateNewViewNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand'; +import { HideDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand'; +import { ImportRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand'; +import { SeeDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand'; +import { AddToFavoritesSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand'; +import { DeleteSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand'; +import { DestroySingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand'; +import { ExportNoteSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand'; +import { ExportSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand'; +import { NavigateToNextRecordSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand'; +import { NavigateToPreviousRecordSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand'; +import { RemoveFromFavoritesSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand'; +import { RestoreSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand'; +import { CancelDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand'; +import { DuplicateDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand'; +import { EditDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand'; +import { SaveDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand'; +import { EditRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand'; +import { SeeVersionWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand'; +import { SeeWorkflowWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand'; +import { StopWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand'; +import { SeeRunsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand'; +import { SeeVersionsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand'; +import { SeeWorkflowWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand'; +import { UseAsDraftWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand'; +import { ActivateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand'; +import { AddNodeWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand'; +import { DeactivateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand'; +import { DiscardDraftWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand'; +import { DuplicateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand'; +import { SeeActiveVersionWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand'; +import { SeeRunsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand'; +import { SeeVersionsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand'; +import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand'; +import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand'; +import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { msg } from '@lingui/core/macro'; +import { AppPath, SettingsPath, SidePanelPages } from 'twenty-shared/types'; +import { IconHistory, IconSearch, IconSparkles } from 'twenty-ui/display'; +import { EngineComponentKey } from '~/generated-metadata/graphql'; + +export const ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP: Record< + EngineComponentKey, + React.ReactNode +> = { + [EngineComponentKey.CREATE_NEW_RECORD]: ( + + ), + [EngineComponentKey.DELETE_SINGLE_RECORD]: , + [EngineComponentKey.DELETE_MULTIPLE_RECORDS]: ( + + ), + [EngineComponentKey.RESTORE_SINGLE_RECORD]: , + [EngineComponentKey.RESTORE_MULTIPLE_RECORDS]: ( + + ), + [EngineComponentKey.DESTROY_SINGLE_RECORD]: , + [EngineComponentKey.DESTROY_MULTIPLE_RECORDS]: ( + + ), + [EngineComponentKey.ADD_TO_FAVORITES]: , + [EngineComponentKey.REMOVE_FROM_FAVORITES]: ( + + ), + [EngineComponentKey.MERGE_MULTIPLE_RECORDS]: , + [EngineComponentKey.DUPLICATE_DASHBOARD]: ( + + ), + [EngineComponentKey.DUPLICATE_WORKFLOW]: ( + + ), + [EngineComponentKey.ACTIVATE_WORKFLOW]: ( + + ), + [EngineComponentKey.DEACTIVATE_WORKFLOW]: ( + + ), + [EngineComponentKey.DISCARD_DRAFT_WORKFLOW]: ( + + ), + [EngineComponentKey.TEST_WORKFLOW]: , + [EngineComponentKey.STOP_WORKFLOW_RUN]: ( + + ), + [EngineComponentKey.USE_AS_DRAFT_WORKFLOW_VERSION]: ( + + ), + [EngineComponentKey.SAVE_DASHBOARD_LAYOUT]: ( + + ), + [EngineComponentKey.TIDY_UP_WORKFLOW]: , + [EngineComponentKey.NAVIGATE_TO_NEXT_RECORD]: ( + + ), + [EngineComponentKey.NAVIGATE_TO_PREVIOUS_RECORD]: ( + + ), + [EngineComponentKey.EXPORT_NOTE_TO_PDF]: , + [EngineComponentKey.EXPORT_FROM_RECORD_INDEX]: ( + + ), + [EngineComponentKey.EXPORT_FROM_RECORD_SHOW]: , + [EngineComponentKey.EXPORT_MULTIPLE_RECORDS]: ( + + ), + [EngineComponentKey.UPDATE_MULTIPLE_RECORDS]: ( + + ), + [EngineComponentKey.IMPORT_RECORDS]: ( + + ), + [EngineComponentKey.EXPORT_VIEW]: , + [EngineComponentKey.SEE_DELETED_RECORDS]: ( + + ), + [EngineComponentKey.CREATE_NEW_VIEW]: ( + + ), + [EngineComponentKey.HIDE_DELETED_RECORDS]: ( + + ), + [EngineComponentKey.EDIT_RECORD_PAGE_LAYOUT]: ( + + ), + [EngineComponentKey.EDIT_DASHBOARD_LAYOUT]: ( + + ), + [EngineComponentKey.CANCEL_DASHBOARD_LAYOUT]: ( + + ), + [EngineComponentKey.GO_TO_PEOPLE]: ( + + ), + [EngineComponentKey.GO_TO_COMPANIES]: ( + + ), + [EngineComponentKey.GO_TO_DASHBOARDS]: ( + + ), + [EngineComponentKey.GO_TO_OPPORTUNITIES]: ( + + ), + [EngineComponentKey.GO_TO_SETTINGS]: ( + + ), + [EngineComponentKey.GO_TO_TASKS]: ( + + ), + [EngineComponentKey.GO_TO_NOTES]: ( + + ), + [EngineComponentKey.GO_TO_WORKFLOWS]: ( + + ), + [EngineComponentKey.GO_TO_RUNS]: ( + + ), + [EngineComponentKey.SEARCH_RECORDS]: ( + + ), + [EngineComponentKey.SEARCH_RECORDS_FALLBACK]: ( + + ), + [EngineComponentKey.ASK_AI]: ( + + ), + [EngineComponentKey.VIEW_PREVIOUS_AI_CHATS]: ( + + ), + [EngineComponentKey.SEE_ACTIVE_VERSION_WORKFLOW]: ( + + ), + [EngineComponentKey.SEE_RUNS_WORKFLOW]: ( + + ), + [EngineComponentKey.SEE_VERSIONS_WORKFLOW]: ( + + ), + [EngineComponentKey.ADD_NODE_WORKFLOW]: ( + + ), + [EngineComponentKey.SEE_VERSION_WORKFLOW_RUN]: ( + + ), + [EngineComponentKey.SEE_WORKFLOW_WORKFLOW_RUN]: ( + + ), + [EngineComponentKey.SEE_RUNS_WORKFLOW_VERSION]: ( + + ), + [EngineComponentKey.SEE_WORKFLOW_WORKFLOW_VERSION]: ( + + ), + [EngineComponentKey.SEE_VERSIONS_WORKFLOW_VERSION]: ( + + ), +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized.ts new file mode 100644 index 0000000000..27fdde1d2a --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized.ts @@ -0,0 +1,12 @@ +import { useRef } from 'react'; + +export const useIsHeadlessEngineCommandEffectInitialized = () => { + // eslint-disable-next-line twenty/no-state-useref + const isInitializedRef = useRef(false); + + const setIsInitialized = (value: boolean) => { + isInitializedRef.current = value; + }; + + return { isInitializedRef, setIsInitialized }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountEngineCommand.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountEngineCommand.ts new file mode 100644 index 0000000000..00f7370414 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountEngineCommand.ts @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState'; +import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState'; +import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState'; +import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; +import { type EngineComponentKey } from '~/generated-metadata/graphql'; + +export const useMountEngineCommand = () => { + const store = useStore(); + + const mountEngineCommand = useCallback( + ( + engineCommandId: string, + contextStoreInstanceId: string, + engineComponentKey: EngineComponentKey, + ) => { + const objectMetadataItemId = store.get( + contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const objectMetadataItems = store.get(objectMetadataItemsSelector.atom); + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === objectMetadataItemId, + ); + + const currentViewId = store.get( + contextStoreCurrentViewIdComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const targetedRecordsRule = store.get( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const selectedRecords = ( + targetedRecordsRule.mode === 'selection' + ? targetedRecordsRule.selectedRecordIds + : [] + ) + .map((id) => store.get(recordStoreFamilyState.atomFamily(id))) + .filter(isDefined); + + const filters = store.get( + contextStoreFiltersComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const filterGroups = store.get( + contextStoreFilterGroupsComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const anyFieldFilterValue = store.get( + contextStoreAnyFieldFilterValueComponentState.atomFamily({ + instanceId: contextStoreInstanceId, + }), + ); + + const currentWorkspaceMember = store.get( + currentWorkspaceMemberState.atom, + ); + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const userTimezone = + currentWorkspaceMember?.timeZone !== 'system' + ? (currentWorkspaceMember?.timeZone ?? systemTimeZone) + : systemTimeZone; + + const graphqlFilter = isDefined(objectMetadataItem) + ? computeContextStoreFilters({ + contextStoreTargetedRecordsRule: targetedRecordsRule, + contextStoreFilters: filters, + contextStoreFilterGroups: filterGroups, + objectMetadataItem, + filterValueDependencies: { + currentWorkspaceMemberId: currentWorkspaceMember?.id, + timeZone: userTimezone, + }, + contextStoreAnyFieldFilterValue: anyFieldFilterValue, + }) + : null; + + const recordIndexId = + objectMetadataItem && currentViewId + ? getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + currentViewId, + ) + : null; + + store.set(mountedEngineCommandsState.atom, (previousMap) => { + const newMap = new Map(previousMap); + + newMap.set(engineCommandId, { + engineComponentKey, + contextStoreInstanceId, + objectMetadataItem: objectMetadataItem ?? null, + currentViewId, + recordIndexId, + targetedRecordsRule, + selectedRecords, + graphqlFilter, + }); + + return newMap; + }); + }, + [store], + ); + + return mountEngineCommand; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountedEngineCommandContext.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountedEngineCommandContext.ts new file mode 100644 index 0000000000..39f144ae91 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountedEngineCommandContext.ts @@ -0,0 +1,22 @@ +import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext'; +import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const useMountedEngineCommandContext = () => { + const engineCommandId = useAvailableComponentInstanceIdOrThrow( + EngineCommandComponentInstanceContext, + ); + + const mountedEngineCommands = useAtomStateValue(mountedEngineCommandsState); + const context = mountedEngineCommands.get(engineCommandId); + + if (!isDefined(context)) { + throw new Error( + 'Engine command mount context not found. Make sure the command was mounted via the engine command mount flow.', + ); + } + + return context; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useUnmountEngineCommand.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useUnmountEngineCommand.ts new file mode 100644 index 0000000000..caa8858a20 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useUnmountEngineCommand.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; + +import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState'; +import { useStore } from 'jotai'; + +export const useUnmountEngineCommand = () => { + const store = useStore(); + + const unmountEngineCommand = useCallback( + (engineCommandId: string) => { + store.set(mountedEngineCommandsState.atom, (previousMap) => { + const newMap = new Map(previousMap); + + newMap.delete(engineCommandId); + + return newMap; + }); + }, + [store], + ); + + return unmountEngineCommand; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..5a24591a0a --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx @@ -0,0 +1,42 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { useIncrementalDeleteManyRecords } from '@/object-record/hooks/useIncrementalDeleteManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { isDefined } from 'twenty-shared/utils'; + +export const DeleteMultipleRecordsCommand = () => { + const { recordIndexId, objectMetadataItem, graphqlFilter } = + useMountedEngineCommandContext(); + + if ( + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) || + !isDefined(graphqlFilter) + ) { + throw new Error( + 'Record index ID, object metadata item, and graphql filter are required to delete multiple records', + ); + } + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const { incrementalDeleteManyRecords } = useIncrementalDeleteManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: graphqlFilter, + pageSize: DEFAULT_QUERY_PAGE_SIZE, + delayInMsBetweenMutations: 50, + }); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); + await incrementalDeleteManyRecords(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..42f6db5b3b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx @@ -0,0 +1,61 @@ +import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { t } from '@lingui/core/macro'; +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const DestroyMultipleRecordsCommand = () => { + const { recordIndexId, objectMetadataItem, graphqlFilter } = + useMountedEngineCommandContext(); + + if ( + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) || + !isDefined(graphqlFilter) + ) { + throw new Error( + 'Record index ID, object metadata item, and graphql filter are required to destroy multiple records', + ); + } + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const deletedAtFilter: RecordGqlOperationFilter = { + deletedAt: { is: 'NOT_NULL' }, + }; + + const combinedFilter = { + ...graphqlFilter, + ...deletedAtFilter, + }; + + const { incrementalDestroyManyRecords } = useIncrementalDestroyManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: combinedFilter, + pageSize: DEFAULT_QUERY_PAGE_SIZE, + delayInMsBetweenMutations: 50, + }); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + + resetTableRowSelection(); + + await incrementalDestroyManyRecords(); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..1bea3b5a6e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand.tsx @@ -0,0 +1,24 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordIndexExportRecords } from '@/object-record/record-index/export/hooks/useRecordIndexExportRecords'; +import { isDefined } from 'twenty-shared/utils'; + +export const ExportMultipleRecordsCommand = () => { + const { objectMetadataItem, recordIndexId } = + useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) { + throw new Error( + 'Object metadata item and record index ID are required to export multiple records', + ); + } + + const { download } = useRecordIndexExportRecords({ + delayMs: 100, + objectMetadataItem, + recordIndexId, + filename: `${objectMetadataItem.nameSingular}.csv`, + }); + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..8d2b992283 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand.tsx @@ -0,0 +1,29 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useOpenMergeRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenMergeRecordsPageInSidePanel'; +import { isDefined } from 'twenty-shared/utils'; + +export const MergeMultipleRecordsCommand = () => { + const { objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const selectedRecordIds = selectedRecords.map((record) => record.id); + + if (!isDefined(objectMetadataItem) || selectedRecordIds.length === 0) { + throw new Error( + 'Object metadata item and selected records are required to merge multiple records', + ); + } + + const { openMergeRecordsPageInSidePanel } = + useOpenMergeRecordsPageInSidePanel({ + objectNameSingular: objectMetadataItem.nameSingular, + objectRecordIds: selectedRecordIds, + }); + + const handleExecute = () => { + openMergeRecordsPageInSidePanel(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..4ec0ac2d48 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand.tsx @@ -0,0 +1,71 @@ +import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { t } from '@lingui/core/macro'; +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const RestoreMultipleRecordsCommand = () => { + const { recordIndexId, objectMetadataItem, graphqlFilter } = + useMountedEngineCommandContext(); + + if ( + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) || + !isDefined(graphqlFilter) + ) { + throw new Error( + 'Record index ID, object metadata item, and graphql filter are required to restore multiple records', + ); + } + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const { restoreManyRecords } = useRestoreManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const deletedAtFilter: RecordGqlOperationFilter = { + deletedAt: { is: 'NOT_NULL' }, + }; + + const combinedFilter = { + ...graphqlFilter, + ...deletedAtFilter, + }; + + const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: combinedFilter, + limit: DEFAULT_QUERY_PAGE_SIZE, + recordGqlFields: { id: true }, + }); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + const recordsToRestore = await fetchAllRecordIds(); + const recordIdsToRestore = recordsToRestore.map((record) => record.id); + + resetTableRowSelection(); + + await restoreManyRecords({ + idsToRestore: recordIdsToRestore, + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand.tsx new file mode 100644 index 0000000000..6ad8a2cc76 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand.tsx @@ -0,0 +1,18 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useOpenUpdateMultipleRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenUpdateMultipleRecordsPageInSidePanel'; + +export const UpdateMultipleRecordsCommand = () => { + const { contextStoreInstanceId } = useMountedEngineCommandContext(); + + const { openUpdateMultipleRecordsPageInSidePanel } = + useOpenUpdateMultipleRecordsPageInSidePanel({ + contextStoreInstanceId, + }); + + const handleExecute = () => { + openUpdateMultipleRecordsPageInSidePanel(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand.tsx new file mode 100644 index 0000000000..9cf9b38964 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand.tsx @@ -0,0 +1,26 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; +import { isDefined } from 'twenty-shared/utils'; + +export const CreateNewIndexRecordNoSelectionRecordCommand = () => { + const { objectMetadataItem, recordIndexId } = + useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) { + throw new Error( + 'Object metadata item and record index ID are required to create new index record', + ); + } + + const { createNewIndexRecord } = useCreateNewIndexRecord({ + objectMetadataItem, + instanceId: recordIndexId, + }); + + return ( + createNewIndexRecord({ position: 'first' })} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand.tsx new file mode 100644 index 0000000000..b0666f0ccc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand.tsx @@ -0,0 +1,39 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId'; +import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; +import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState'; +import { isDefined } from 'twenty-shared/utils'; + +export const CreateNewViewNoSelectionRecordCommand = () => { + const { currentViewId, recordIndexId } = useMountedEngineCommandContext(); + + const { openDropdown } = useOpenDropdown(); + + if (!isDefined(currentViewId) || !isDefined(recordIndexId)) { + throw new Error( + 'Current view ID and record index ID are required to create new view', + ); + } + + const setViewPickerReferenceViewId = useSetAtomComponentState( + viewPickerReferenceViewIdComponentState, + recordIndexId, + ); + + const { setViewPickerMode } = useViewPickerMode(recordIndexId); + + const handleExecute = () => { + if (currentViewId) { + setViewPickerReferenceViewId(currentViewId); + } + setViewPickerMode('create-empty'); + openDropdown({ + dropdownComponentInstanceIdFromProps: VIEW_PICKER_DROPDOWN_ID, + }); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand.tsx new file mode 100644 index 0000000000..a6ace7cad9 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand.tsx @@ -0,0 +1,49 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter'; +import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const HideDeletedRecordsNoSelectionRecordCommand = () => { + const { objectMetadataItem, recordIndexId } = + useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) { + throw new Error( + 'Object metadata item and record index ID are required to hide deleted records', + ); + } + + const { toggleSoftDeleteFilterState } = useHandleToggleTrashColumnFilter({ + objectNameSingular: objectMetadataItem.nameSingular, + viewBarId: recordIndexId, + recordFiltersInstanceId: recordIndexId, + }); + + const { isRecordFilterAboutSoftDelete } = useCheckIsSoftDeleteFilter(); + + const currentRecordFilters = useAtomComponentStateValue( + currentRecordFiltersComponentState, + recordIndexId, + ); + + const deletedFilter = currentRecordFilters.find( + isRecordFilterAboutSoftDelete, + ); + + const { removeRecordFilter } = useRemoveRecordFilter(recordIndexId); + + const handleExecute = () => { + if (!isDefined(deletedFilter)) { + return; + } + + removeRecordFilter({ recordFilterId: deletedFilter.id }); + toggleSoftDeleteFilterState(false); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand.tsx new file mode 100644 index 0000000000..0433b0fffc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand.tsx @@ -0,0 +1,23 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog'; +import { isDefined } from 'twenty-shared/utils'; + +export const ImportRecordsNoSelectionRecordCommand = () => { + const { objectMetadataItem } = useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata item is required to import records'); + } + + const { openObjectRecordsSpreadsheetImportDialog } = + useOpenObjectRecordsSpreadsheetImportDialog( + objectMetadataItem.nameSingular, + ); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand.tsx new file mode 100644 index 0000000000..2f2a5aacc6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand.tsx @@ -0,0 +1,31 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeDeletedRecordsNoSelectionRecordCommand = () => { + const { objectMetadataItem, recordIndexId } = + useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) { + throw new Error( + 'Object metadata item and record index ID are required to see deleted records', + ); + } + + const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } = + useHandleToggleTrashColumnFilter({ + objectNameSingular: objectMetadataItem.nameSingular, + viewBarId: recordIndexId, + recordFiltersInstanceId: recordIndexId, + }); + + return ( + { + handleToggleTrashColumnFilter(); + toggleSoftDeleteFilterState(true); + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand.tsx new file mode 100644 index 0000000000..eb4c38c27a --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand.tsx @@ -0,0 +1,27 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem'; +import { isDefined } from 'twenty-shared/utils'; + +export const AddToFavoritesSingleRecordCommand = () => { + const { objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const selectedRecord = selectedRecords[0]; + + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata item is required to add to favorites'); + } + + const { createNavigationMenuItem } = useCreateNavigationMenuItem(); + + const handleExecute = () => { + if (!isDefined(selectedRecord)) { + return; + } + + createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand.tsx new file mode 100644 index 0000000000..75d1969fa4 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand.tsx @@ -0,0 +1,59 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/common/hooks/useRemoveNavigationMenuItemByTargetRecordId'; +import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { isDefined } from 'twenty-shared/utils'; + +export const DeleteSingleRecordCommand = () => { + const { recordIndexId, objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if ( + !isDefined(recordId) || + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) + ) { + throw new Error( + 'Record ID, record index ID, and object metadata are required to delete single record', + ); + } + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const { deleteOneRecord } = useDeleteOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { navigationMenuItems, workspaceNavigationMenuItems } = + useNavigationMenuItemsData(); + + const { removeNavigationMenuItemsByTargetRecordIds } = + useRemoveNavigationMenuItemByTargetRecordId(); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + + resetTableRowSelection(); + + const foundNavigationMenuItem = [ + ...navigationMenuItems, + ...workspaceNavigationMenuItems, + ].find((item) => item.targetRecordId === recordId); + + if (isDefined(foundNavigationMenuItem)) { + removeNavigationMenuItemsByTargetRecordIds([recordId]); + } + + await deleteOneRecord(recordId); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand.tsx new file mode 100644 index 0000000000..890c817bdd --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand.tsx @@ -0,0 +1,56 @@ +import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { t } from '@lingui/core/macro'; +import { AppPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +export const DestroySingleRecordCommand = () => { + const { recordIndexId, objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if ( + !isDefined(recordId) || + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) + ) { + throw new Error( + 'Record ID, record index ID, and object metadata are required to destroy single record', + ); + } + + const navigateApp = useNavigateApp(); + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const { destroyOneRecord } = useDestroyOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); + + await destroyOneRecord(recordId); + navigateApp(AppPath.RecordIndexPage, { + objectNamePlural: objectMetadataItem.namePlural, + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand.tsx new file mode 100644 index 0000000000..4da3425668 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand.tsx @@ -0,0 +1,44 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { isDefined } from 'twenty-shared/utils'; + +export const ExportNoteSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + const recordId = selectedRecord?.id; + + if (!isDefined(recordId) || !isDefined(selectedRecord)) { + throw new Error( + 'Record ID and selected record are required to export note to PDF', + ); + } + + const filename = `${(selectedRecord.title || 'Untitled Note').replace(/[<>:"/\\|?*]/g, '-')}`; + + const handleExecute = async () => { + const initialBody = selectedRecord.bodyV2?.blocknote; + + let parsedBody = []; + + // TODO: Remove this once we have removed the old rich text + try { + parsedBody = JSON.parse(initialBody); + } catch { + // oxlint-disable-next-line no-console + console.warn( + `Failed to parse body for record ${recordId}, for rich text version 'v2'`, + ); + // oxlint-disable-next-line no-console + console.warn(initialBody); + } + + const { exportBlockNoteEditorToPdf } = await import( + '@/command-menu-item/record/single-record/utils/exportBlockNoteEditorToPdf' + ); + + await exportBlockNoteEditorToPdf(parsedBody, filename); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand.tsx new file mode 100644 index 0000000000..c1a5968674 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand.tsx @@ -0,0 +1,30 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useExportSingleRecord } from '@/object-record/record-show/hooks/useExportSingleRecord'; +import { isDefined } from 'twenty-shared/utils'; + +export const ExportSingleRecordCommand = () => { + const { objectMetadataItem, currentViewId, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if ( + !isDefined(currentViewId) || + !isDefined(recordId) || + !isDefined(objectMetadataItem) + ) { + throw new Error( + 'Current view ID, record ID, and object metadata are required to export single record', + ); + } + + const filename = `${objectMetadataItem.nameSingular}.csv`; + const { download } = useExportSingleRecord({ + filename, + objectMetadataItem, + recordId, + }); + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand.tsx new file mode 100644 index 0000000000..cee4b730c4 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand.tsx @@ -0,0 +1,24 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; +import { isDefined } from 'twenty-shared/utils'; + +export const NavigateToNextRecordSingleRecordCommand = () => { + const { objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if (!isDefined(recordId) || !isDefined(objectMetadataItem)) { + throw new Error( + 'Record ID and object metadata are required to navigate to next record', + ); + } + + const { navigateToNextRecord } = useRecordShowPagePagination( + objectMetadataItem.nameSingular, + recordId, + ); + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand.tsx new file mode 100644 index 0000000000..27e6684576 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand.tsx @@ -0,0 +1,26 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; +import { isDefined } from 'twenty-shared/utils'; + +export const NavigateToPreviousRecordSingleRecordCommand = () => { + const { objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if (!isDefined(recordId) || !isDefined(objectMetadataItem)) { + throw new Error( + 'Record ID and object metadata are required to navigate to previous record', + ); + } + + const { navigateToPreviousRecord } = useRecordShowPagePagination( + objectMetadataItem.nameSingular, + recordId, + ); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand.tsx new file mode 100644 index 0000000000..15d6258939 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand.tsx @@ -0,0 +1,42 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem'; +import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; +import { isDefined } from 'twenty-shared/utils'; + +export const RemoveFromFavoritesSingleRecordCommand = () => { + const { selectedRecords, objectMetadataItem } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if (!isDefined(recordId) || !isDefined(objectMetadataItem)) { + throw new Error( + 'Record ID and object metadata are required to remove from favorites', + ); + } + + const { navigationMenuItems, workspaceNavigationMenuItems } = + useNavigationMenuItemsData(); + + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + + const foundNavigationMenuItem = [ + ...navigationMenuItems, + ...workspaceNavigationMenuItems, + ].find( + (item) => + item.targetRecordId === recordId && + item.targetObjectMetadataId === objectMetadataItem.id, + ); + + const handleExecute = () => { + if (!isDefined(foundNavigationMenuItem)) { + return; + } + + deleteNavigationMenuItem(foundNavigationMenuItem.id); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand.tsx new file mode 100644 index 0000000000..b22e03c1bd --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand.tsx @@ -0,0 +1,51 @@ +import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +export const RestoreSingleRecordCommand = () => { + const { recordIndexId, objectMetadataItem, selectedRecords } = + useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + if ( + !isDefined(recordId) || + !isDefined(recordIndexId) || + !isDefined(objectMetadataItem) + ) { + throw new Error( + 'Record ID, record index ID, and object metadata are required to restore single record', + ); + } + + const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + + const { restoreManyRecords } = useRestoreManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const handleExecute = async () => { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); + + await restoreManyRecords({ + idsToRestore: [recordId], + }); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand.tsx new file mode 100644 index 0000000000..e341b1572e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand.tsx @@ -0,0 +1,34 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isDefined } from 'twenty-shared/utils'; + +export const CancelDashboardSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord)) { + throw new Error('Selected record is required to cancel dashboard'); + } + + const pageLayoutId = selectedRecord.pageLayoutId; + + const { closeSidePanelMenu } = useSidePanelMenu(); + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { resetDraftPageLayoutToPersistedPageLayout } = + useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId); + + const handleExecute = () => { + closeSidePanelMenu(); + + resetDraftPageLayoutToPersistedPageLayout(); + setIsPageLayoutInEditMode(false); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand.tsx new file mode 100644 index 0000000000..9279a8e28c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand.tsx @@ -0,0 +1,44 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useDuplicateDashboard } from '@/dashboards/hooks/useDuplicateDashboard'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +export const DuplicateDashboardSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const { duplicateDashboard } = useDuplicateDashboard(); + const navigate = useNavigateApp(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const { t } = useLingui(); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to duplicate dashboard'); + } + + const handleExecute = async () => { + const result = await duplicateDashboard(recordId); + + if (isDefined(result) && isNonEmptyString(result.id)) { + enqueueSuccessSnackBar({ + message: t`Dashboard duplicated successfully`, + }); + + navigate(AppPath.RecordShowPage, { + objectNameSingular: CoreObjectNameSingular.Dashboard, + objectRecordId: result.id, + }); + } else { + enqueueErrorSnackBar({ + message: t`Failed to duplicate dashboard`, + }); + } + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand.tsx new file mode 100644 index 0000000000..5e374aca65 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand.tsx @@ -0,0 +1,28 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { isDefined } from 'twenty-shared/utils'; +import { useResetLocationHash } from 'twenty-ui/utilities'; + +export const EditDashboardSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord)) { + throw new Error('Selected record is required to edit dashboard'); + } + + const pageLayoutId = selectedRecord.pageLayoutId; + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { resetLocationHash } = useResetLocationHash(); + + const handleExecute = () => { + setIsPageLayoutInEditMode(true); + resetLocationHash(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx new file mode 100644 index 0000000000..fb26507faa --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx @@ -0,0 +1,35 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isDefined } from 'twenty-shared/utils'; + +export const SaveDashboardSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord)) { + throw new Error('Selected record is required to save dashboard'); + } + + const pageLayoutId = selectedRecord.pageLayoutId; + + const { savePageLayout } = useSavePageLayout(pageLayoutId); + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { closeSidePanelMenu } = useSidePanelMenu(); + + const handleExecute = async () => { + const result = await savePageLayout(); + + if (result.status === 'successful') { + closeSidePanelMenu(); + setIsPageLayoutInEditMode(false); + } + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/CancelRecordPageLayoutSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/CancelRecordPageLayoutSingleRecordCommand.tsx new file mode 100644 index 0000000000..60655c69ee --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/CancelRecordPageLayoutSingleRecordCommand.tsx @@ -0,0 +1,38 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow'; +import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isDefined } from 'twenty-shared/utils'; + +export const CancelRecordPageLayoutSingleRecordCommand = () => { + const { objectMetadataItem } = useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem)) { + throw new Error( + 'Object metadata item is required to cancel record page layout', + ); + } + + const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({ + targetObjectNameSingular: objectMetadataItem.nameSingular, + }); + + const { closeSidePanelMenu } = useSidePanelMenu(); + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { resetDraftPageLayoutToPersistedPageLayout } = + useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId); + + const handleExecute = () => { + closeSidePanelMenu(); + + resetDraftPageLayoutToPersistedPageLayout(); + setIsPageLayoutInEditMode(false); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand.tsx new file mode 100644 index 0000000000..05032e916b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand.tsx @@ -0,0 +1,32 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { isDefined } from 'twenty-shared/utils'; +import { useResetLocationHash } from 'twenty-ui/utilities'; + +export const EditRecordPageLayoutSingleRecordCommand = () => { + const { objectMetadataItem } = useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem)) { + throw new Error( + 'Object metadata item is required to edit record page layout', + ); + } + + const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({ + targetObjectNameSingular: objectMetadataItem.nameSingular, + }); + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { resetLocationHash } = useResetLocationHash(); + + const handleExecute = () => { + setIsPageLayoutInEditMode(true); + resetLocationHash(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand.tsx new file mode 100644 index 0000000000..e121fff299 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand.tsx @@ -0,0 +1,44 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow'; +import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups'; +import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout'; +import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isDefined } from 'twenty-shared/utils'; + +export const SaveRecordPageLayoutSingleRecordCommand = () => { + const { objectMetadataItem } = useMountedEngineCommandContext(); + + if (!isDefined(objectMetadataItem)) { + throw new Error( + 'Object metadata item is required to save record page layout', + ); + } + + const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({ + targetObjectNameSingular: objectMetadataItem.nameSingular, + }); + + const { savePageLayout } = useSavePageLayout(pageLayoutId); + + const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups(); + + const { setIsPageLayoutInEditMode } = + useSetIsPageLayoutInEditMode(pageLayoutId); + + const { closeSidePanelMenu } = useSidePanelMenu(); + + const handleExecute = async () => { + const result = await savePageLayout(); + + if (result.status === 'successful') { + await saveFieldsWidgetGroups(pageLayoutId); + + closeSidePanelMenu(); + setIsPageLayoutInEditMode(false); + } + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand.tsx new file mode 100644 index 0000000000..75ce2dcd88 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand.tsx @@ -0,0 +1,26 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeVersionWorkflowRunSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if ( + !isDefined(selectedRecord) || + !isDefined(selectedRecord?.workflowVersion?.id) + ) { + throw new Error('Selected record is required to see version workflow run'); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand.tsx new file mode 100644 index 0000000000..38c8536e04 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand.tsx @@ -0,0 +1,23 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeWorkflowWorkflowRunSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord) || !isDefined(selectedRecord?.workflow?.id)) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx new file mode 100644 index 0000000000..e6c07295a8 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx @@ -0,0 +1,37 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { useStopWorkflowRun } from '@/workflow/hooks/useStopWorkflowRun'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const StopWorkflowRunSingleRecordCommand = () => { + const { targetedRecordsRule, graphqlFilter } = + useMountedEngineCommandContext(); + + const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ + objectNameSingular: CoreObjectNameSingular.WorkflowRun, + filter: isDefined(graphqlFilter) ? graphqlFilter : undefined, + limit: DEFAULT_QUERY_PAGE_SIZE, + recordGqlFields: { id: true }, + }); + + const { stopWorkflowRun } = useStopWorkflowRun(); + + const handleExecute = async () => { + if (targetedRecordsRule.mode === 'selection') { + for (const selectedRecordId of targetedRecordsRule.selectedRecordIds) { + await stopWorkflowRun(selectedRecordId); + } + } else { + const records = await fetchAllRecordIds(); + + for (const record of records) { + await stopWorkflowRun(record.id); + } + } + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand.tsx new file mode 100644 index 0000000000..7174f5b55e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand.tsx @@ -0,0 +1,59 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { AppPath, ViewFilterOperand } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +const SeeRunsWorkflowVersionSingleRecordCommandContent = ({ + workflowId, + recordId, +}: { + workflowId: string; + recordId: string; +}) => { + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId); + + return ( + + ); +}; + +export const SeeRunsWorkflowVersionSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const selectedRecord = selectedRecords[0]; + + const workflowId = selectedRecord?.workflow?.id; + + if (!isDefined(recordId) || !isDefined(workflowId)) { + throw new Error( + 'Record ID and workflow ID are required to see runs workflow version', + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand.tsx new file mode 100644 index 0000000000..83d66ac0ae --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand.tsx @@ -0,0 +1,47 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { AppPath, ViewFilterOperand } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +const SeeVersionsWorkflowVersionSingleRecordCommandContent = ({ + workflowId, +}: { + workflowId: string; +}) => { + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId); + + return ( + + ); +}; + +export const SeeVersionsWorkflowVersionSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord) || !isDefined(selectedRecord.workflowId)) { + throw new Error( + 'Selected record and workflow ID are required to see versions workflow version', + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand.tsx new file mode 100644 index 0000000000..bc730b144e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand.tsx @@ -0,0 +1,25 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeWorkflowWorkflowVersionSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const selectedRecord = selectedRecords[0]; + + if (!isDefined(selectedRecord) || !isDefined(selectedRecord?.workflow?.id)) { + throw new Error( + 'Selected record and workflow ID are required to see workflow workflow version', + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand.tsx new file mode 100644 index 0000000000..c0b93c58ea --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand.tsx @@ -0,0 +1,86 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal'; +import { OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID } from '@/workflow/constants/OverrideWorkflowDraftConfirmationModalId'; +import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion'; +import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { useState } from 'react'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +const UseAsDraftWorkflowVersionSingleRecordCommandContent = ({ + workflowId, + workflowVersionId, +}: { + workflowId: string; + workflowVersionId: string; +}) => { + const { openModal } = useModal(); + const workflow = useWorkflowWithCurrentVersion(workflowId); + const { createDraftFromWorkflowVersion } = + useCreateDraftFromWorkflowVersion(); + const navigate = useNavigateApp(); + const [hasNavigated, setHasNavigated] = useState(false); + + const hasAlreadyDraftVersion = + workflow?.versions.some((version) => version.status === 'DRAFT') || false; + + const handleExecute = () => { + if (!isDefined(workflow) || hasNavigated) { + return; + } + + if (hasAlreadyDraftVersion) { + openModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID); + } else { + const executeCommandWithoutWaiting = async () => { + await createDraftFromWorkflowVersion({ + workflowId, + workflowVersionIdToCopy: workflowVersionId, + }); + + navigate(AppPath.RecordShowPage, { + objectNameSingular: CoreObjectNameSingular.Workflow, + objectRecordId: workflowId, + }); + + setHasNavigated(true); + }; + + executeCommandWithoutWaiting(); + } + }; + + return ( + <> + + + + ); +}; + +export const UseAsDraftWorkflowVersionSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const workflowVersion = useWorkflowVersion(recordId ?? ''); + + if (!recordId || !isDefined(workflowVersion?.workflow?.id)) { + throw new Error( + 'Record ID and workflow ID are required to use as draft workflow version', + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..9d4a65121c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand.tsx @@ -0,0 +1,32 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { isDefined } from 'twenty-shared/utils'; + +export const ActivateWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + const recordId = selectedRecords[0]?.id; + + const { activateWorkflowVersion } = useActivateWorkflowVersion(); + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to activate workflow'); + } + + const handleExecute = () => { + if (!isDefined(workflowWithCurrentVersion)) { + return; + } + + activateWorkflowVersion({ + workflowVersionId: workflowWithCurrentVersion.currentVersion.id, + workflowId: workflowWithCurrentVersion.id, + }); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..9788bde43c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand.tsx @@ -0,0 +1,30 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useSidePanelWorkflowNavigation } from '@/side-panel/pages/workflow/hooks/useSidePanelWorkflowNavigation'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { isDefined } from 'twenty-shared/utils'; + +export const AddNodeWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + const { openWorkflowCreateStepInSidePanel } = + useSidePanelWorkflowNavigation(); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to add node workflow'); + } + + const handleExecute = () => { + if (!isDefined(workflowWithCurrentVersion)) { + return; + } + + openWorkflowCreateStepInSidePanel(workflowWithCurrentVersion.id); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..b3c8072a0f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand.tsx @@ -0,0 +1,31 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { isDefined } from 'twenty-shared/utils'; + +export const DeactivateWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion(); + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to deactivate workflow'); + } + + const handleExecute = () => { + if (!isDefined(workflowWithCurrentVersion)) { + return; + } + + deactivateWorkflowVersion({ + workflowVersionId: workflowWithCurrentVersion.currentVersion.id, + }); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..d94ebde5ac --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand.tsx @@ -0,0 +1,31 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { isDefined } from 'twenty-shared/utils'; + +export const DiscardDraftWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const { deleteOneWorkflowVersion } = useDeleteOneWorkflowVersion(); + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to discard draft workflow'); + } + + const handleExecute = () => { + if (!isDefined(workflowWithCurrentVersion)) { + return; + } + + deleteOneWorkflowVersion({ + workflowVersionId: workflowWithCurrentVersion.currentVersion.id, + }); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..437c2d7000 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand.tsx @@ -0,0 +1,55 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useDuplicateWorkflow } from '@/workflow/hooks/useDuplicateWorkflow'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +export const DuplicateWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const workflow = useWorkflowWithCurrentVersion(recordId ?? ''); + const { duplicateWorkflow } = useDuplicateWorkflow(); + const navigate = useNavigateApp(); + const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); + const { t } = useLingui(); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to duplicate workflow'); + } + + const handleExecute = async () => { + if (!isDefined(workflow) || !isDefined(workflow.currentVersion)) { + return; + } + + const result = await duplicateWorkflow({ + workflowIdToDuplicate: workflow.id, + workflowVersionIdToCopy: workflow.currentVersion.id, + }); + + if (isDefined(result) && isNonEmptyString(result.workflowId)) { + enqueueSuccessSnackBar({ + message: t`Workflow duplicated successfully`, + }); + + navigate(AppPath.RecordShowPage, { + objectNameSingular: CoreObjectNameSingular.Workflow, + objectRecordId: result.workflowId, + }); + } else { + enqueueErrorSnackBar({ + message: t`Failed to duplicate workflow`, + }); + } + }; + + return isDefined(workflow) ? ( + + ) : null; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..16cec564c0 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand.tsx @@ -0,0 +1,33 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useActiveWorkflowVersion } from '@/workflow/hooks/useActiveWorkflowVersion'; +import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeActiveVersionWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + + const { workflowVersion, loading } = useActiveWorkflowVersion({ + workflowId: recordId ?? '', + }); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to see active version workflow'); + } + + if (loading) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..c02f76c22b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand.tsx @@ -0,0 +1,35 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { AppPath, ViewFilterOperand } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeRunsWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to see runs workflow'); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..b77b3723df --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand.tsx @@ -0,0 +1,35 @@ +import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { AppPath, ViewFilterOperand } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const SeeVersionsWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to see versions workflow'); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..3b2de6bb74 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand.tsx @@ -0,0 +1,32 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { isDefined } from 'twenty-shared/utils'; + +export const TestWorkflowSingleRecordCommand = () => { + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const { runWorkflowVersion } = useRunWorkflowVersion(); + const workflowWithCurrentVersion = useWorkflowWithCurrentVersion( + recordId ?? '', + ); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to test workflow'); + } + + const handleExecute = () => { + if (!isDefined(workflowWithCurrentVersion)) { + return; + } + + runWorkflowVersion({ + workflowVersionId: workflowWithCurrentVersion.currentVersion.id, + workflowId: workflowWithCurrentVersion.id, + }); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx new file mode 100644 index 0000000000..df4843e0af --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx @@ -0,0 +1,42 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext'; +import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; +import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId'; +import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; +import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; + +export const TidyUpWorkflowSingleRecordCommand = () => { + const store = useStore(); + const { selectedRecords } = useMountedEngineCommandContext(); + + const recordId = selectedRecords[0]?.id; + const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion(); + const instanceId = getWorkflowVisualizerComponentInstanceId({ + recordId: recordId ?? '', + }); + const { getUpdatableWorkflowVersion } = + useGetUpdatableWorkflowVersionOrThrow(instanceId); + + if (!isDefined(recordId)) { + throw new Error('Record ID is required to tidy up workflow'); + } + + const handleExecute = async () => { + const workflowDiagramAtom = workflowDiagramComponentState.atomFamily({ + instanceId, + }); + const workflowDiagram = store.get(workflowDiagramAtom); + + if (!isDefined(workflowDiagram)) { + return; + } + + const workflowVersionId = await getUpdatableWorkflowVersion(); + + await tidyUpWorkflowVersion(workflowVersionId, workflowDiagram); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext.ts new file mode 100644 index 0000000000..a2a1c93c95 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const EngineCommandComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/states/mountedEngineCommandsState.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/states/mountedEngineCommandsState.ts new file mode 100644 index 0000000000..8036b58641 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/states/mountedEngineCommandsState.ts @@ -0,0 +1,9 @@ +import { type MountedEngineCommandState } from '@/command-menu-item/engine-command/types/MountedEngineCommandContext'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const mountedEngineCommandsState = createAtomState< + Map +>({ + key: 'mountedEngineCommandsState', + defaultValue: new Map(), +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/types/MountedEngineCommandContext.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/types/MountedEngineCommandContext.ts new file mode 100644 index 0000000000..e12203254c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/types/MountedEngineCommandContext.ts @@ -0,0 +1,19 @@ +import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { + type Nullable, + type RecordGqlOperationFilter, +} from 'twenty-shared/types'; +import { type EngineComponentKey } from '~/generated-metadata/graphql'; + +export type MountedEngineCommandState = { + engineComponentKey: EngineComponentKey; + contextStoreInstanceId: string; + objectMetadataItem: Nullable; + currentViewId: Nullable; + recordIndexId: Nullable; + targetedRecordsRule: ContextStoreTargetedRecordsRule; + selectedRecords: ObjectRecord[]; + graphqlFilter: Nullable; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx index b912281e70..f1e334b7c3 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx @@ -2,7 +2,7 @@ import { CommandMenuContext, type CommandMenuContextType, } from '@/command-menu-item/contexts/CommandMenuContext'; -import { useCommandMenuItemFrontComponentCommands } from '@/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands'; +import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend'; import { type CommandMenuContextApi } from 'twenty-shared/types'; type CommandMenuContextProviderServerItemsContentProps = { @@ -21,8 +21,9 @@ export const CommandMenuContextProviderServerItemsContent = ({ }: CommandMenuContextProviderServerItemsContentProps & { commandMenuContextApi: CommandMenuContextApi; }) => { - const commandMenuItemFrontComponentActions = - useCommandMenuItemFrontComponentCommands(commandMenuContextApi); + const commandMenuItems = useCommandMenuItemsFromBackend( + commandMenuContextApi, + ); return ( {children} diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx similarity index 90% rename from packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands.tsx rename to packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx index 8a6fbab622..0129c273e0 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx @@ -1,14 +1,16 @@ -import { ENGINE_COMPONENT_KEY_COMPONENT_MAP } from '@/command-menu-item/constants/EngineComponentKeyComponentMap'; import { Command } from '@/command-menu-item/display/components/Command'; import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem'; +import { useMountEngineCommand } from '@/command-menu-item/engine-command/hooks/useMountEngineCommand'; import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope'; import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent'; import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { type CommandMenuContextApi } from 'twenty-shared/types'; @@ -129,6 +131,12 @@ type BuildCommandMenuItemFromStandardKeyParams = { isPinned: boolean; getIcon: ReturnType['getIcon']; commandMenuContextApi: CommandMenuContextApi; + mountEngineCommand: ( + engineCommandId: string, + contextStoreInstanceId: string, + engineComponentKey: EngineComponentKey, + ) => void; + contextStoreInstanceId: string; }; const buildCommandItemFromEngineKey = ({ @@ -139,10 +147,14 @@ const buildCommandItemFromEngineKey = ({ isPinned, getIcon, commandMenuContextApi, + mountEngineCommand, + contextStoreInstanceId, }: BuildCommandMenuItemFromStandardKeyParams) => { const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON); - const component = ENGINE_COMPONENT_KEY_COMPONENT_MAP[engineComponentKey]; + const handleClick = () => { + mountEngineCommand(item.id, contextStoreInstanceId, engineComponentKey); + }; return { type, @@ -159,16 +171,21 @@ const buildCommandItemFromEngineKey = ({ item.conditionalAvailabilityExpression, commandMenuContextApi, ), - component, + component: , }; }; -export const useCommandMenuItemFrontComponentCommands = ( +export const useCommandMenuItemsFromBackend = ( commandMenuContextApi: CommandMenuContextApi, ) => { const { getIcon } = useIcons(); const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel(); const mountHeadlessFrontComponent = useMountHeadlessFrontComponent(); + const mountEngineCommand = useMountEngineCommand(); + + const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow( + ContextStoreComponentInstanceContext, + ); const contextStoreIsPageInEditMode = useAtomComponentStateValue( contextStoreIsPageInEditModeComponentState, @@ -236,6 +253,8 @@ export const useCommandMenuItemFrontComponentCommands = ( isPinned, getIcon, commandMenuContextApi, + mountEngineCommand, + contextStoreInstanceId, }); } diff --git a/packages/twenty-front/src/modules/context-store/hooks/useContextStoreObjectMetadataItemOrThrow.ts b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreObjectMetadataItemOrThrow.ts index 2ef1d9b225..fac0e4d280 100644 --- a/packages/twenty-front/src/modules/context-store/hooks/useContextStoreObjectMetadataItemOrThrow.ts +++ b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreObjectMetadataItemOrThrow.ts @@ -1,7 +1,7 @@ import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; -import { isDefined } from 'twenty-shared/utils'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { isDefined } from 'twenty-shared/utils'; export const useContextStoreObjectMetadataItemOrThrow = ( contextStoreInstanceId?: string, diff --git a/packages/twenty-front/src/modules/front-components/components/HeadlessFrontComponentMountRoot.tsx b/packages/twenty-front/src/modules/front-components/components/HeadlessFrontComponentMountRoot.tsx index 810617f2f2..df6463c160 100644 --- a/packages/twenty-front/src/modules/front-components/components/HeadlessFrontComponentMountRoot.tsx +++ b/packages/twenty-front/src/modules/front-components/components/HeadlessFrontComponentMountRoot.tsx @@ -1,5 +1,7 @@ import { Suspense, lazy } from 'react'; +import { CommandMenuItemErrorBoundary } from '@/command-menu-item/display/components/CommandMenuItemErrorBoundary'; +import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent'; import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState'; import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -16,34 +18,37 @@ export const HeadlessFrontComponentMountRoot = () => { const mountedHeadlessFrontComponentMaps = useAtomStateValue( mountedHeadlessFrontComponentMapsState, ); - - if (mountedHeadlessFrontComponentMaps.size === 0) { - return null; - } + const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent(); return ( <> {[...mountedHeadlessFrontComponentMaps.entries()].map( ([frontComponentId, mountContext]) => ( - - - - - + unmountHeadlessFrontComponent(frontComponentId)} + > + + + + + + ), )} diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts index 346cb6b26c..f943e4e70d 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -5,13 +5,13 @@ import { } from 'twenty-sdk/front-component-renderer'; import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types'; -import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal'; import { currentUserState } from '@/auth/states/currentUserState'; -import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; -import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; -import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal'; import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh'; import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent'; +import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -75,7 +75,7 @@ export const useFrontComponentExecutionContext = ({ const openCommandConfirmationModal: FrontComponentHostCommunicationApi['openCommandConfirmationModal'] = async ({ title, subtitle, confirmButtonText, confirmButtonAccent }) => { openConfirmationModal({ - frontComponentId, + caller: { type: 'frontComponent', frontComponentId }, title, subtitle, confirmButtonText, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts index b9e6af535f..c3810b5ae9 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useRemoveRecordFilter.ts @@ -6,9 +6,10 @@ import { useStore } from 'jotai'; import { useCallback, useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; -export const useRemoveRecordFilter = () => { +export const useRemoveRecordFilter = (instanceId?: string) => { const currentRecordFilters = useAtomComponentStateCallbackState( currentRecordFiltersComponentState, + instanceId, ); const store = useStore(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts index ad0c29fa16..00328508a7 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useUpsertRecordFilter.ts @@ -5,9 +5,10 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h import { useStore } from 'jotai'; import { useCallback, useContext } from 'react'; -export const useUpsertRecordFilter = () => { +export const useUpsertRecordFilter = (instanceId?: string) => { const currentRecordFilters = useAtomComponentStateCallbackState( currentRecordFiltersComponentState, + instanceId, ); const store = useStore(); diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts index 5d9a1e55db..ccf68222c7 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts @@ -89,6 +89,7 @@ export const useRecordIndexLazyFetchRecords = ({ const findManyRecordsParams = useFindManyRecordIndexTableParams( objectMetadataItem.nameSingular, + recordIndexId, ); const queryFilter = computeContextStoreFilters({ @@ -102,6 +103,7 @@ export const useRecordIndexLazyFetchRecords = ({ const visibleRecordFields = useAtomComponentSelectorValue( visibleRecordFieldsComponentSelector, + recordIndexId, ); const finalColumns: Pick< diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index c8c2b30476..c22e70db26 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -17,6 +17,7 @@ import { export const useFindManyRecordIndexTableParams = ( objectNameSingular: string, + instanceId?: string, ) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, @@ -31,14 +32,17 @@ export const useFindManyRecordIndexTableParams = ( const currentRecordFilterGroups = useAtomComponentStateValue( currentRecordFilterGroupsComponentState, + instanceId, ); const currentRecordSorts = useAtomComponentStateValue( currentRecordSortsComponentState, + instanceId, ); const currentRecordFilters = useAtomComponentStateValue( currentRecordFiltersComponentState, + instanceId, ); const { filterValueDependencies } = useFilterValueDependencies(); @@ -52,6 +56,7 @@ export const useFindManyRecordIndexTableParams = ( const anyFieldFilterValue = useAtomComponentStateValue( anyFieldFilterValueComponentState, + instanceId, ); const { recordGqlOperationFilter: anyFieldFilter } = diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts index b5c68197d0..c6487d0a10 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts @@ -16,11 +16,13 @@ import { ViewFilterOperand } from 'twenty-shared/types'; type UseHandleToggleTrashColumnFilterProps = { objectNameSingular: string; viewBarId: string; + recordFiltersInstanceId?: string; }; export const useHandleToggleTrashColumnFilter = ({ viewBarId, objectNameSingular, + recordFiltersInstanceId, }: UseHandleToggleTrashColumnFilterProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, @@ -35,7 +37,7 @@ export const useHandleToggleTrashColumnFilter = ({ viewBarId, ); - const { upsertRecordFilter } = useUpsertRecordFilter(); + const { upsertRecordFilter } = useUpsertRecordFilter(recordFiltersInstanceId); const handleToggleTrashColumnFilter = useCallback(() => { const trashFieldMetadata = objectMetadataItem.fields.find( diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts index 5f5c5c6584..c652e0bee6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts @@ -9,11 +9,14 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState export const useBuildRecordInputFromFilters = ({ objectMetadataItem, + instanceId, }: { objectMetadataItem: ObjectMetadataItem; + instanceId?: string; }) => { const currentRecordFilters = useAtomComponentStateValue( currentRecordFiltersComponentState, + instanceId, ); const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index 44e5d90885..5374185ceb 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -25,23 +25,28 @@ import { useNavigateApp } from '~/hooks/useNavigateApp'; type UseCreateNewIndexRecordProps = { objectMetadataItem: ObjectMetadataItem; + instanceId?: string; }; export const useCreateNewIndexRecord = ({ objectMetadataItem, + instanceId, }: UseCreateNewIndexRecordProps) => { const recordGroupDefinitions = useAtomComponentSelectorValue( recordGroupDefinitionsComponentSelector, + instanceId, ); const store = useStore(); const recordIndexRecordIdsByGroupCallbackState = useAtomComponentFamilyStateCallbackState( recordIndexRecordIdsByGroupComponentFamilyState, + instanceId, ); const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( recordIndexGroupFieldMetadataItemComponentState, + instanceId, ); const { openRecordInSidePanel } = useOpenRecordInSidePanel(); @@ -59,6 +64,7 @@ export const useCreateNewIndexRecord = ({ const { buildRecordInputFromFilters } = useBuildRecordInputFromFilters({ objectMetadataItem, + instanceId, }); const { buildRecordInputFromRLSPredicates } = diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx b/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx index 341baa0e95..7a01f635ba 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx @@ -1,6 +1,7 @@ import { ThreadWebWorker, release, retain } from '@quilted/threads'; import { RemoteReceiver } from '@remote-dom/core/receivers'; import { useEffect, useRef } from 'react'; +import { type ConfirmationModalCaller } from 'twenty-shared/types'; import { type CommandConfirmationModalResult } from '../../../sdk/front-component-api/globals/frontComponentHostCommunicationApi'; import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi'; import { type WorkerExports } from '../../types/WorkerExports'; @@ -11,7 +12,7 @@ const COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME = 'command-menu-item-confirmation-modal-result'; type CommandMenuItemConfirmationModalResultBrowserEventDetail = { - frontComponentId: string; + caller: ConfirmationModalCaller; confirmationResult: CommandConfirmationModalResult; }; @@ -73,9 +74,12 @@ export const FrontComponentWorkerEffect = ({ const commandMenuItemConfirmationModalResultBrowserEventDetail = event.detail; + const caller = + commandMenuItemConfirmationModalResultBrowserEventDetail.caller; + if ( - commandMenuItemConfirmationModalResultBrowserEventDetail.frontComponentId !== - frontComponentId + caller.type !== 'frontComponent' || + caller.frontComponentId !== frontComponentId ) { return; } diff --git a/packages/twenty-sdk/src/sdk/command/Command.tsx b/packages/twenty-sdk/src/sdk/command/Command.tsx index beff14490e..a506f1aa30 100644 --- a/packages/twenty-sdk/src/sdk/command/Command.tsx +++ b/packages/twenty-sdk/src/sdk/command/Command.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react'; import { - enqueueSnackbar, - getFrontComponentCommandErrorDedupeKey, unmountFrontComponent, useFrontComponentId, } from '../front-component-api'; @@ -24,20 +22,9 @@ export const Command = ({ execute }: CommandProps) => { setHasExecuted(true); const run = async () => { - try { - await execute(); - } catch (error) { - if (error instanceof Error) { - await enqueueSnackbar({ - message: 'Command failed', - detailedMessage: error.message, - variant: 'error', - dedupeKey: getFrontComponentCommandErrorDedupeKey(frontComponentId), - }); - } - } finally { - await unmountFrontComponent(); - } + await execute(); + + await unmountFrontComponent(); }; run(); diff --git a/packages/twenty-sdk/src/sdk/command/CommandLink.tsx b/packages/twenty-sdk/src/sdk/command/CommandLink.tsx index 8c25e45314..dc461e5ef4 100644 --- a/packages/twenty-sdk/src/sdk/command/CommandLink.tsx +++ b/packages/twenty-sdk/src/sdk/command/CommandLink.tsx @@ -4,8 +4,6 @@ import { type NavigateOptions } from 'react-router-dom'; import { type AppPath } from 'twenty-shared/types'; import { type getAppPath } from 'twenty-shared/utils'; import { - enqueueSnackbar, - getFrontComponentCommandErrorDedupeKey, navigate, unmountFrontComponent, useFrontComponentId, @@ -36,20 +34,9 @@ export const CommandLink = ({ setHasExecuted(true); const run = async () => { - try { - await navigate(to, params, queryParams, options); - } catch (error) { - if (error instanceof Error) { - await enqueueSnackbar({ - message: 'Command failed', - detailedMessage: error.message, - variant: 'error', - dedupeKey: getFrontComponentCommandErrorDedupeKey(frontComponentId), - }); - } - } finally { - await unmountFrontComponent(); - } + await navigate(to, params, queryParams, options); + + await unmountFrontComponent(); }; run(); diff --git a/packages/twenty-sdk/src/sdk/command/CommandModal.tsx b/packages/twenty-sdk/src/sdk/command/CommandModal.tsx index 94f4423301..5e6175ed55 100644 --- a/packages/twenty-sdk/src/sdk/command/CommandModal.tsx +++ b/packages/twenty-sdk/src/sdk/command/CommandModal.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react'; import { - enqueueSnackbar, - getFrontComponentCommandErrorDedupeKey, openCommandConfirmationModal, unmountFrontComponent, useFrontComponentId, @@ -36,30 +34,20 @@ export const CommandModal = ({ setHasExecuted(true); const run = async () => { - try { - const commandConfirmationModalResult = - await openCommandConfirmationModal({ - title, - subtitle, - confirmButtonText, - confirmButtonAccent, - }); + const commandConfirmationModalResult = await openCommandConfirmationModal( + { + title, + subtitle, + confirmButtonText, + confirmButtonAccent, + }, + ); - if (commandConfirmationModalResult === 'confirm') { - await execute(); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - - await enqueueSnackbar({ - message: 'Command failed', - detailedMessage: message, - variant: 'error', - dedupeKey: getFrontComponentCommandErrorDedupeKey(frontComponentId), - }); - } finally { - await unmountFrontComponent(); + if (commandConfirmationModalResult === 'confirm') { + await execute(); } + + await unmountFrontComponent(); }; run(); diff --git a/packages/twenty-sdk/src/sdk/command/CommandOpenSidePanelPage.tsx b/packages/twenty-sdk/src/sdk/command/CommandOpenSidePanelPage.tsx index ae96c4b77b..9d18b2284a 100644 --- a/packages/twenty-sdk/src/sdk/command/CommandOpenSidePanelPage.tsx +++ b/packages/twenty-sdk/src/sdk/command/CommandOpenSidePanelPage.tsx @@ -1,6 +1,4 @@ import { - enqueueSnackbar, - getFrontComponentCommandErrorDedupeKey, openSidePanelPage, unmountFrontComponent, useFrontComponentId, @@ -38,25 +36,14 @@ export const CommandOpenSidePanelPage = ({ const run = async () => { onClick?.(); - try { - await openSidePanelPage({ - page, - pageTitle, - pageIcon, - shouldResetSearchState, - }); - } catch (error) { - if (error instanceof Error) { - await enqueueSnackbar({ - message: 'Command failed', - detailedMessage: error.message, - variant: 'error', - dedupeKey: getFrontComponentCommandErrorDedupeKey(frontComponentId), - }); - } - } finally { - await unmountFrontComponent(); - } + await openSidePanelPage({ + page, + pageTitle, + pageIcon, + shouldResetSearchState, + }); + + await unmountFrontComponent(); }; run(); diff --git a/packages/twenty-shared/src/types/ConfirmationModalCaller.ts b/packages/twenty-shared/src/types/ConfirmationModalCaller.ts new file mode 100644 index 0000000000..d075ba1100 --- /dev/null +++ b/packages/twenty-shared/src/types/ConfirmationModalCaller.ts @@ -0,0 +1,3 @@ +export type ConfirmationModalCaller = + | { type: 'frontComponent'; frontComponentId: string } + | { type: 'engineCommand'; engineCommandId: string }; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 23f1fe3aa6..147993ff8a 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -53,6 +53,7 @@ export { } from './composite-types/rich-text.composite-type'; export type { CompositeFieldSubFieldName } from './CompositeFieldSubFieldNameType'; export type { ConfigVariableValue } from './ConfigVariableValue'; +export type { ConfirmationModalCaller } from './ConfirmationModalCaller'; export { ConnectedAccountProvider } from './ConnectedAccountProvider'; export { CoreObjectNameSingular } from './CoreObjectNameSingular'; export { CrudOperationType } from './CrudOperationType';