From 4cfd738312c249fb72b921ec394aa6ca013e9be1 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:43:46 +0530 Subject: [PATCH] Headless action modal (#18270) https://github.com/user-attachments/assets/809a281f-3c38-41df-99db-e780941acf9f --- .vscode/settings.json | 1 + .../ActionMenuConfirmationModalManager.tsx | 67 ++++++++++++++++ .../ActionMenuConfirmationModalId.ts | 2 + ...ConfirmationModalResultBrowserEventName.ts | 2 + .../hooks/useActionMenuConfirmationModal.ts | 48 ++++++++++++ .../actionMenuConfirmationModalState.ts | 18 +++++ ...nfirmationModalResultBrowserEventDetail.ts | 6 ++ .../app/components/AppRouterProviders.tsx | 2 + .../useFrontComponentExecutionContext.ts | 14 ++++ .../FrontComponentRenderer.stories.tsx | 16 ++-- .../__stories__/UILibraries.stories.tsx | 9 ++- .../components/FrontComponentRenderer.tsx | 2 + .../components/FrontComponentWorkerEffect.tsx | 43 ++++++++++ .../remote/worker/remote-worker.ts | 12 +++ .../createActionConfirmationModalBridge.ts | 65 ++++++++++++++++ .../FrontComponentHostCommunicationApi.ts | 2 + .../types/WorkerExports.ts | 1 + .../twenty-sdk/src/sdk/action/ActionModal.tsx | 78 +++++++++++++++++++ packages/twenty-sdk/src/sdk/action/index.ts | 2 + .../functions/openActionConfirmationModal.ts | 18 +++++ .../frontComponentHostCommunicationApi.ts | 16 ++++ .../src/sdk/front-component-api/index.ts | 5 ++ packages/twenty-sdk/src/sdk/index.ts | 15 +++- 23 files changed, 429 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager.tsx create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId.ts create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalResultBrowserEventName.ts create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/hooks/useActionMenuConfirmationModal.ts create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/states/actionMenuConfirmationModalState.ts create mode 100644 packages/twenty-front/src/modules/action-menu/confirmation-modal/types/ActionMenuConfirmationModalResultBrowserEventDetail.ts create mode 100644 packages/twenty-sdk/src/front-component-renderer/remote/worker/utils/createActionConfirmationModalBridge.ts create mode 100644 packages/twenty-sdk/src/sdk/action/ActionModal.tsx create mode 100644 packages/twenty-sdk/src/sdk/front-component-api/functions/openActionConfirmationModal.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ea5526e76ab..d168d1054ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,6 +48,7 @@ "search.exclude": { "**/.yarn": true }, + "eslint.useFlatConfig": true, "eslint.debug": true, "files.associations": { ".cursorrules": "markdown" diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager.tsx b/packages/twenty-front/src/modules/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager.tsx new file mode 100644 index 00000000000..34663df5d61 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager.tsx @@ -0,0 +1,67 @@ +import { ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalResultBrowserEventName'; +import { ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId'; +import { actionMenuConfirmationModalConfigState } from '@/action-menu/confirmation-modal/states/actionMenuConfirmationModalState'; +import { + type ActionMenuConfirmationModalResult, + type ActionMenuConfirmationModalResultBrowserEventDetail, +} from '@/action-menu/confirmation-modal/types/ActionMenuConfirmationModalResultBrowserEventDetail'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; +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'; + +export const ActionMenuConfirmationModalManager = () => { + const actionMenuConfirmationModalConfig = useAtomStateValue( + actionMenuConfirmationModalConfigState, + ); + const isModalOpened = useAtomComponentStateValue( + isModalOpenedComponentState, + ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID, + ); + const setActionMenuConfirmationModalConfig = useSetAtomState( + actionMenuConfirmationModalConfigState, + ); + + const callerId = actionMenuConfirmationModalConfig?.frontComponentId; + + const emitConfirmationResult = ( + confirmationResult: ActionMenuConfirmationModalResult, + ) => { + if (!callerId) { + return; + } + + window.dispatchEvent( + new CustomEvent( + ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, + { + detail: { + frontComponentId: callerId, + confirmationResult, + }, + }, + ), + ); + + setActionMenuConfirmationModalConfig(null); + }; + + if (!actionMenuConfirmationModalConfig || !isModalOpened) { + return null; + } + + return ( + emitConfirmationResult('confirm')} + onClose={() => emitConfirmationResult('cancel')} + confirmButtonText={actionMenuConfirmationModalConfig.confirmButtonText} + confirmButtonAccent={ + actionMenuConfirmationModalConfig.confirmButtonAccent + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId.ts b/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId.ts new file mode 100644 index 00000000000..069ceccf763 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId.ts @@ -0,0 +1,2 @@ +export const ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID = + 'action-menu-confirmation-modal'; diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalResultBrowserEventName.ts b/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalResultBrowserEventName.ts new file mode 100644 index 00000000000..1cead0f7412 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalResultBrowserEventName.ts @@ -0,0 +1,2 @@ +export const ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME = + 'action-menu-confirmation-modal-result'; diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/hooks/useActionMenuConfirmationModal.ts b/packages/twenty-front/src/modules/action-menu/confirmation-modal/hooks/useActionMenuConfirmationModal.ts new file mode 100644 index 00000000000..578c4e9b0ed --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/hooks/useActionMenuConfirmationModal.ts @@ -0,0 +1,48 @@ +import { useStore } from 'jotai'; +import { useCallback } from 'react'; + +import { ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/action-menu/confirmation-modal/constants/ActionMenuConfirmationModalId'; +import { + type ActionMenuConfirmationModalConfig, + actionMenuConfirmationModalConfigState, +} from '@/action-menu/confirmation-modal/states/actionMenuConfirmationModalState'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; + +export const useActionMenuConfirmationModal = () => { + const store = useStore(); + const setActionMenuConfirmationModalConfig = useSetAtomState( + actionMenuConfirmationModalConfigState, + ); + const { openModal } = useModal(); + + const openConfirmationModal = useCallback( + (config: ActionMenuConfirmationModalConfig) => { + const existingActionMenuConfirmationModalConfig = store.get( + actionMenuConfirmationModalConfigState.atom, + ); + const isActionMenuConfirmationModalOpened = store.get( + isModalOpenedComponentState.atomFamily({ + instanceId: ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID, + }), + ); + + if ( + existingActionMenuConfirmationModalConfig !== null || + isActionMenuConfirmationModalOpened + ) { + throw new Error( + 'Action menu confirmation modal is already active for another front component', + ); + } + + setActionMenuConfirmationModalConfig(config); + + openModal(ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID); + }, + [store, setActionMenuConfirmationModalConfig, openModal], + ); + + return { openConfirmationModal }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/states/actionMenuConfirmationModalState.ts b/packages/twenty-front/src/modules/action-menu/confirmation-modal/states/actionMenuConfirmationModalState.ts new file mode 100644 index 00000000000..14583169baf --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/states/actionMenuConfirmationModalState.ts @@ -0,0 +1,18 @@ +import { type ReactNode } from 'react'; + +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { type ButtonAccent } from 'twenty-ui/input'; + +export type ActionMenuConfirmationModalConfig = { + frontComponentId: string; + title: string; + subtitle: ReactNode; + confirmButtonText?: string; + confirmButtonAccent?: ButtonAccent; +}; + +export const actionMenuConfirmationModalConfigState = + createAtomState({ + key: 'actionMenuConfirmationModalConfigState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/action-menu/confirmation-modal/types/ActionMenuConfirmationModalResultBrowserEventDetail.ts b/packages/twenty-front/src/modules/action-menu/confirmation-modal/types/ActionMenuConfirmationModalResultBrowserEventDetail.ts new file mode 100644 index 00000000000..3b6ff2217bb --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/confirmation-modal/types/ActionMenuConfirmationModalResultBrowserEventDetail.ts @@ -0,0 +1,6 @@ +export type ActionMenuConfirmationModalResult = 'confirm' | 'cancel'; + +export type ActionMenuConfirmationModalResultBrowserEventDetail = { + frontComponentId: string; + confirmationResult: ActionMenuConfirmationModalResult; +}; diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index a634cf56fd8..c844351ed05 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -1,4 +1,5 @@ import { AgentChatProvider } from '@/ai/components/AgentChatProvider'; +import { ActionMenuConfirmationModalManager } from '@/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; import { MetadataGater } from '@/metadata-store/components/MetadataGater'; import { IsAppMetadataReadyEffect } from '@/metadata-store/effect-components/IsAppMetadataReadyEffect'; @@ -71,6 +72,7 @@ export const AppRouterProviders = () => { + 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 7aa4eb1919c..35d82aee2a4 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -5,6 +5,7 @@ import { } from 'twenty-sdk/front-component-renderer'; import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types'; +import { useActionMenuConfirmationModal } from '@/action-menu/confirmation-modal/hooks/useActionMenuConfirmationModal'; import { currentUserState } from '@/auth/states/currentUserState'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; @@ -31,6 +32,7 @@ export const useFrontComponentExecutionContext = ({ const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({ frontComponentId, }); + const { openConfirmationModal } = useActionMenuConfirmationModal(); const { navigateCommandMenu } = useNavigateCommandMenu(); const setCommandMenuSearch = useSetAtomState(commandMenuSearchState); const { getIcon } = useIcons(); @@ -70,6 +72,17 @@ export const useFrontComponentExecutionContext = ({ } }; + const openActionConfirmationModal: FrontComponentHostCommunicationApi['openActionConfirmationModal'] = + async ({ title, subtitle, confirmButtonText, confirmButtonAccent }) => { + openConfirmationModal({ + frontComponentId, + title, + subtitle, + confirmButtonText, + confirmButtonAccent, + }); + }; + const enqueueSnackbar: FrontComponentHostCommunicationApi['enqueueSnackbar'] = async ({ message, @@ -125,6 +138,7 @@ export const useFrontComponentExecutionContext = ({ navigate, requestAccessTokenRefresh, openSidePanelPage, + openActionConfirmationModal, enqueueSnackbar, unmountFrontComponent, closeSidePanel, diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx index f9175f32026..cd33e26d019 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/FrontComponentRenderer.stories.tsx @@ -16,6 +16,7 @@ const meta: Meta = { args: { onError: errorHandler, applicationAccessToken: 'fake-token', + executionContext: { frontComponentId: 'storybook-test', userId: null }, }, beforeEach: () => { errorHandler.mockClear(); @@ -117,16 +118,15 @@ export const SdkContext: Story = { ...createComponentStory('sdk-context-example'), args: { ...createComponentStory('sdk-context-example').args, - executionContext: { userId: 'test-user-abc-123' }, + executionContext: { + frontComponentId: 'sdk-context-test', + userId: 'test-user-abc-123', + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByTestId( - 'sdk-context-component', - {}, - { timeout: 30000 }, - ); + await canvas.findByTestId('sdk-context-component', {}, { timeout: 30000 }); const userIdElement = await canvas.findByTestId('sdk-context-user-id'); expect(userIdElement).toBeVisible(); @@ -138,9 +138,7 @@ export const SdkContext: Story = { const button = await canvas.findByTestId('sdk-context-button'); await userEvent.click(button); - const renderCount = await canvas.findByTestId( - 'sdk-context-render-count', - ); + const renderCount = await canvas.findByTestId('sdk-context-render-count'); expect(renderCount).toHaveTextContent('Renders: 1'); expect(userIdElement).toHaveTextContent('test-user-abc-123'); diff --git a/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx b/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx index ea9655da48c..7d5b3c5796e 100644 --- a/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/__stories__/UILibraries.stories.tsx @@ -16,6 +16,7 @@ const meta: Meta = { args: { onError: errorHandler, applicationAccessToken: 'fake-token', + executionContext: { frontComponentId: 'storybook-test', userId: null }, }, beforeEach: () => { errorHandler.mockClear(); @@ -138,7 +139,7 @@ const twentyUiTest: Story['play'] = async ({ canvasElement }) => { export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', { play: twentyUiTest, }); -export const TwentyUiPreact: Story = createComponentStory( - 'twenty-ui-example', - { runtime: 'preact', play: twentyUiTest }, -); +export const TwentyUiPreact: Story = createComponentStory('twenty-ui-example', { + runtime: 'preact', + play: twentyUiTest, +}); diff --git a/packages/twenty-sdk/src/front-component-renderer/host/components/FrontComponentRenderer.tsx b/packages/twenty-sdk/src/front-component-renderer/host/components/FrontComponentRenderer.tsx index 1e0e6dce933..3ea32f9ecee 100644 --- a/packages/twenty-sdk/src/front-component-renderer/host/components/FrontComponentRenderer.tsx +++ b/packages/twenty-sdk/src/front-component-renderer/host/components/FrontComponentRenderer.tsx @@ -50,6 +50,7 @@ export const FrontComponentRenderer = ({ componentUrl={componentUrl} applicationAccessToken={applicationAccessToken} apiUrl={apiUrl} + frontComponentId={executionContext.frontComponentId} frontComponentHostCommunicationApi={frontComponentHostCommunicationApi} setReceiver={setReceiver} setThread={setThread} @@ -64,6 +65,7 @@ export const FrontComponentRenderer = ({ setThread, applicationAccessToken, apiUrl, + executionContext.frontComponentId, ]); return ( 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 3ab59e95590..fbebbbe7ec0 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,14 +1,25 @@ import { ThreadWebWorker, release, retain } from '@quilted/threads'; import { RemoteReceiver } from '@remote-dom/core/receivers'; import { useEffect, useRef } from 'react'; +import { type ActionConfirmationModalResult } from '../../../sdk/front-component-api/globals/frontComponentHostCommunicationApi'; import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi'; import { type WorkerExports } from '../../types/WorkerExports'; import { createRemoteWorker } from '../worker/utils/createRemoteWorker'; +// Must match ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME in twenty-front +const ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME = + 'action-menu-confirmation-modal-result'; + +type ActionMenuConfirmationModalResultBrowserEventDetail = { + frontComponentId: string; + confirmationResult: ActionConfirmationModalResult; +}; + type FrontComponentWorkerEffectProps = { componentUrl: string; applicationAccessToken?: string; apiUrl?: string; + frontComponentId: string; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; setReceiver: React.Dispatch>; setThread: React.Dispatch< @@ -24,6 +35,7 @@ export const FrontComponentWorkerEffect = ({ componentUrl, applicationAccessToken, apiUrl, + frontComponentId, frontComponentHostCommunicationApi, setReceiver, setThread, @@ -55,6 +67,32 @@ export const FrontComponentWorkerEffect = ({ exports: frontComponentHostCommunicationApi, }); + const handleActionMenuConfirmationModalResultBrowserEvent = ( + event: CustomEvent, + ) => { + const actionMenuConfirmationModalResultBrowserEventDetail = event.detail; + + if ( + actionMenuConfirmationModalResultBrowserEventDetail.frontComponentId !== + frontComponentId + ) { + return; + } + + thread.imports + .onConfirmationModalResult( + actionMenuConfirmationModalResultBrowserEventDetail.confirmationResult, + ) + .catch((error: Error) => { + setError(error); + }); + }; + + window.addEventListener( + ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, + handleActionMenuConfirmationModalResultBrowserEvent as EventListener, + ); + setThread(thread); thread.imports @@ -71,6 +109,10 @@ export const FrontComponentWorkerEffect = ({ isInitializedRef.current = true; return () => { + window.removeEventListener( + ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME, + handleActionMenuConfirmationModalResultBrowserEvent as EventListener, + ); setThread(null); worker.terminate(); isInitializedRef.current = false; @@ -79,6 +121,7 @@ export const FrontComponentWorkerEffect = ({ componentUrl, applicationAccessToken, apiUrl, + frontComponentId, setError, setReceiver, setThread, diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts index 3ab7a6fef38..63de3ad72a4 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts @@ -24,6 +24,10 @@ import { type FrontComponentHostCommunicationApi } from '../../types/FrontCompon import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext'; import { type WorkerExports } from '../../types/WorkerExports'; import { exposeGlobals } from '../utils/exposeGlobals'; +import { + createOpenActionConfirmationModalAdapter, + handleActionConfirmationModalResult, +} from './utils/createActionConfirmationModalBridge'; import { setWorkerEnv } from './utils/setWorkerEnv'; installStylePropertyOnRemoteElements(); @@ -97,6 +101,8 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication hostApi.requestAccessTokenRefresh; frontComponentHostCommunicationApi.openSidePanelPage = hostApi.openSidePanelPage; + frontComponentHostCommunicationApi.openActionConfirmationModal = + createOpenActionConfirmationModalAdapter(hostApi); frontComponentHostCommunicationApi.unmountFrontComponent = hostApi.unmountFrontComponent; frontComponentHostCommunicationApi.enqueueSnackbar = @@ -104,6 +110,11 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel; }; +const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] = + async (result) => { + await handleActionConfirmationModalResult(result); + }; + const updateContext: WorkerExports['updateContext'] = async ( context: FrontComponentExecutionContext, ) => { @@ -113,5 +124,6 @@ const updateContext: WorkerExports['updateContext'] = async ( ThreadWebWorker.self.export({ render, initializeHostCommunicationApi, + onConfirmationModalResult, updateContext, }); diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/worker/utils/createActionConfirmationModalBridge.ts b/packages/twenty-sdk/src/front-component-renderer/remote/worker/utils/createActionConfirmationModalBridge.ts new file mode 100644 index 00000000000..9802a5a9b79 --- /dev/null +++ b/packages/twenty-sdk/src/front-component-renderer/remote/worker/utils/createActionConfirmationModalBridge.ts @@ -0,0 +1,65 @@ +import { + type ActionConfirmationModalResult, + type OpenActionConfirmationModalFunction, +} from '@/sdk/front-component-api/globals/frontComponentHostCommunicationApi'; +import { type FrontComponentHostCommunicationApi } from '../../../types/FrontComponentHostCommunicationApi'; + +type ActionConfirmationModalPromiseCallbacks = { + resolve: (result: ActionConfirmationModalResult) => void; + reject: (error: Error) => void; +}; + +let pendingActionConfirmationModalPromiseCallbacks: ActionConfirmationModalPromiseCallbacks | null = + null; + +const clearPendingActionConfirmationModalPromiseCallbacks = () => { + pendingActionConfirmationModalPromiseCallbacks = null; +}; + +export const createOpenActionConfirmationModalAdapter = ( + hostApi: Pick< + FrontComponentHostCommunicationApi, + 'openActionConfirmationModal' + >, +): OpenActionConfirmationModalFunction => { + return async (params) => { + if (pendingActionConfirmationModalPromiseCallbacks !== null) { + throw new Error( + 'A confirmation modal is already pending for this front component', + ); + } + + let rejectActionConfirmationModalPromise: (error: Error) => void = () => {}; + + const actionConfirmationModalResultPromise = + new Promise((resolve, reject) => { + rejectActionConfirmationModalPromise = reject; + pendingActionConfirmationModalPromiseCallbacks = { resolve, reject }; + }); + + try { + await hostApi.openActionConfirmationModal(params); + } catch (error) { + clearPendingActionConfirmationModalPromiseCallbacks(); + + rejectActionConfirmationModalPromise( + error instanceof Error ? error : new Error(String(error)), + ); + } + + return actionConfirmationModalResultPromise; + }; +}; + +export const handleActionConfirmationModalResult = async ( + result: ActionConfirmationModalResult, +) => { + if (pendingActionConfirmationModalPromiseCallbacks === null) { + return; + } + + const currentActionConfirmationModalPromiseCallbacks = + pendingActionConfirmationModalPromiseCallbacks; + clearPendingActionConfirmationModalPromiseCallbacks(); + currentActionConfirmationModalPromiseCallbacks.resolve(result); +}; diff --git a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts index e744834d7a7..8ec3b11ae48 100644 --- a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts @@ -2,6 +2,7 @@ import { type CloseSidePanelFunction, type EnqueueSnackbarFunction, type NavigateFunction, + type OpenActionConfirmationModalHostFunction, type OpenSidePanelPageFunction, type RequestAccessTokenRefreshFunction, type UnmountFrontComponentFunction, @@ -11,6 +12,7 @@ export type FrontComponentHostCommunicationApi = { navigate: NavigateFunction; requestAccessTokenRefresh: RequestAccessTokenRefreshFunction; openSidePanelPage: OpenSidePanelPageFunction; + openActionConfirmationModal: OpenActionConfirmationModalHostFunction; unmountFrontComponent: UnmountFrontComponentFunction; enqueueSnackbar: EnqueueSnackbarFunction; closeSidePanel: CloseSidePanelFunction; diff --git a/packages/twenty-sdk/src/front-component-renderer/types/WorkerExports.ts b/packages/twenty-sdk/src/front-component-renderer/types/WorkerExports.ts index fcf73acfbd1..4a355349b20 100644 --- a/packages/twenty-sdk/src/front-component-renderer/types/WorkerExports.ts +++ b/packages/twenty-sdk/src/front-component-renderer/types/WorkerExports.ts @@ -9,4 +9,5 @@ export type WorkerExports = { ) => Promise; initializeHostCommunicationApi: () => Promise; updateContext: (context: FrontComponentExecutionContext) => Promise; + onConfirmationModalResult: (result: 'confirm' | 'cancel') => Promise; }; diff --git a/packages/twenty-sdk/src/sdk/action/ActionModal.tsx b/packages/twenty-sdk/src/sdk/action/ActionModal.tsx new file mode 100644 index 00000000000..b298a90e4fe --- /dev/null +++ b/packages/twenty-sdk/src/sdk/action/ActionModal.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; + +import { + enqueueSnackbar, + getFrontComponentActionErrorDedupeKey, + openActionConfirmationModal, + unmountFrontComponent, + useFrontComponentId, + type ActionConfirmationModalAccent, +} from '../front-component-api'; + +export type ActionModalProps = { + title: string; + subtitle: string; + execute: () => void | Promise; + confirmButtonText?: string; + confirmButtonAccent?: ActionConfirmationModalAccent; +}; + +export const ActionModal = ({ + title, + subtitle, + execute, + confirmButtonText, + confirmButtonAccent, +}: ActionModalProps) => { + const [hasExecuted, setHasExecuted] = useState(false); + + const frontComponentId = useFrontComponentId(); + + useEffect(() => { + if (hasExecuted) { + return; + } + + setHasExecuted(true); + + const run = async () => { + try { + const actionConfirmationModalResult = await openActionConfirmationModal( + { + title, + subtitle, + confirmButtonText, + confirmButtonAccent, + }, + ); + + if (actionConfirmationModalResult === 'confirm') { + await execute(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + await enqueueSnackbar({ + message: 'Action failed', + detailedMessage: message, + variant: 'error', + dedupeKey: getFrontComponentActionErrorDedupeKey(frontComponentId), + }); + } finally { + await unmountFrontComponent(); + } + }; + + run(); + }, [ + title, + subtitle, + execute, + confirmButtonText, + confirmButtonAccent, + hasExecuted, + frontComponentId, + ]); + + return null; +}; diff --git a/packages/twenty-sdk/src/sdk/action/index.ts b/packages/twenty-sdk/src/sdk/action/index.ts index 712559c9fe8..e026fd5c1e8 100644 --- a/packages/twenty-sdk/src/sdk/action/index.ts +++ b/packages/twenty-sdk/src/sdk/action/index.ts @@ -4,3 +4,5 @@ export { ActionLink } from './ActionLink'; export type { ActionLinkProps } from './ActionLink'; export { ActionOpenSidePanelPage } from './ActionOpenSidePanelPage'; export type { ActionOpenSidePanelPageProps } from './ActionOpenSidePanelPage'; +export { ActionModal } from './ActionModal'; +export type { ActionModalProps } from './ActionModal'; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/functions/openActionConfirmationModal.ts b/packages/twenty-sdk/src/sdk/front-component-api/functions/openActionConfirmationModal.ts new file mode 100644 index 00000000000..8e4b02c93f4 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component-api/functions/openActionConfirmationModal.ts @@ -0,0 +1,18 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + frontComponentHostCommunicationApi, + type OpenActionConfirmationModalFunction, +} from '../globals/frontComponentHostCommunicationApi'; + +export const openActionConfirmationModal: OpenActionConfirmationModalFunction = + (params) => { + const openActionConfirmationModalFunction = + frontComponentHostCommunicationApi.openActionConfirmationModal; + + if (!isDefined(openActionConfirmationModalFunction)) { + throw new Error('openActionConfirmationModalFunction is not set'); + } + + return openActionConfirmationModalFunction(params); + }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts index 4cfa533b3b2..478ef35063d 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts @@ -20,6 +20,17 @@ export type OpenSidePanelPageFunction = (params: { shouldResetSearchState?: boolean; }) => Promise; +export type ActionConfirmationModalResult = 'confirm' | 'cancel'; + +export type ActionConfirmationModalAccent = 'default' | 'blue' | 'danger'; + +export type OpenActionConfirmationModalFunction = (params: { + title: string; + subtitle: string; + confirmButtonText?: string; + confirmButtonAccent?: ActionConfirmationModalAccent; +}) => Promise; + export type UnmountFrontComponentFunction = () => Promise; export type EnqueueSnackbarFunction = ( @@ -30,10 +41,15 @@ export type CloseSidePanelFunction = () => Promise; export type RequestAccessTokenRefreshFunction = () => Promise; +export type OpenActionConfirmationModalHostFunction = ( + params: Parameters[0], +) => Promise; + export type FrontComponentHostCommunicationApiStore = { navigate?: NavigateFunction; requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction; openSidePanelPage?: OpenSidePanelPageFunction; + openActionConfirmationModal?: OpenActionConfirmationModalFunction; unmountFrontComponent?: UnmountFrontComponentFunction; enqueueSnackbar?: EnqueueSnackbarFunction; closeSidePanel?: CloseSidePanelFunction; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/index.ts b/packages/twenty-sdk/src/sdk/front-component-api/index.ts index 85ac1a4057b..465997f8321 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/index.ts @@ -19,6 +19,7 @@ export { setFrontComponentExecutionContext } from './context/frontComponentConte export { closeSidePanel } from './functions/closeSidePanel'; export { enqueueSnackbar } from './functions/enqueueSnackbar'; export { navigate } from './functions/navigate'; +export { openActionConfirmationModal } from './functions/openActionConfirmationModal'; export { openSidePanelPage } from './functions/openSidePanelPage'; export { unmountFrontComponent } from './functions/unmountFrontComponent'; export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext'; @@ -27,6 +28,10 @@ export { useRecordId } from './hooks/useRecordId'; export { useUserId } from './hooks/useUserId'; export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext'; export { getFrontComponentActionErrorDedupeKey } from './utils/getFrontComponentActionErrorDedupeKey'; +export type { + ActionConfirmationModalAccent, + ActionConfirmationModalResult, +} from './globals/frontComponentHostCommunicationApi'; export { ALLOWED_HTML_ELEMENTS } from './constants/AllowedHtmlElements'; export type { AllowedHtmlElement } from './constants/AllowedHtmlElements'; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 74da39b46bd..11911646518 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -73,9 +73,15 @@ export { defineView } from './views/define-view'; export type { ViewConfig } from './views/view-config'; // Action components for front components -export { Action, ActionLink, ActionOpenSidePanelPage } from './action'; +export { + Action, + ActionLink, + ActionModal, + ActionOpenSidePanelPage, +} from './action'; export type { ActionLinkProps, + ActionModalProps, ActionOpenSidePanelPageProps, ActionProps, } from './action'; @@ -105,6 +111,7 @@ export { enqueueSnackbar, getFrontComponentActionErrorDedupeKey, navigate, + openActionConfirmationModal, openSidePanelPage, unmountFrontComponent, useFrontComponentExecutionContext, @@ -112,7 +119,11 @@ export { useRecordId, useUserId, } from './front-component-api'; -export type { FrontComponentExecutionContext } from './front-component-api'; +export type { + ActionConfirmationModalAccent, + ActionConfirmationModalResult, + FrontComponentExecutionContext, +} from './front-component-api'; export { AppPath, CommandMenuPages } from 'twenty-shared/types'; export type {