mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Headless action modal (#18270)
https://github.com/user-attachments/assets/809a281f-3c38-41df-99db-e780941acf9f
This commit is contained in:
parent
0e89c96170
commit
4cfd738312
23 changed files with 429 additions and 15 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -48,6 +48,7 @@
|
|||
"search.exclude": {
|
||||
"**/.yarn": true
|
||||
},
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.debug": true,
|
||||
"files.associations": {
|
||||
".cursorrules": "markdown"
|
||||
|
|
|
|||
|
|
@ -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<ActionMenuConfirmationModalResultBrowserEventDetail>(
|
||||
ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
|
||||
{
|
||||
detail: {
|
||||
frontComponentId: callerId,
|
||||
confirmationResult,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
setActionMenuConfirmationModalConfig(null);
|
||||
};
|
||||
|
||||
if (!actionMenuConfirmationModalConfig || !isModalOpened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
modalInstanceId={ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID}
|
||||
title={actionMenuConfirmationModalConfig.title}
|
||||
subtitle={actionMenuConfirmationModalConfig.subtitle}
|
||||
onConfirmClick={() => emitConfirmationResult('confirm')}
|
||||
onClose={() => emitConfirmationResult('cancel')}
|
||||
confirmButtonText={actionMenuConfirmationModalConfig.confirmButtonText}
|
||||
confirmButtonAccent={
|
||||
actionMenuConfirmationModalConfig.confirmButtonAccent
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID =
|
||||
'action-menu-confirmation-modal';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
|
||||
'action-menu-confirmation-modal-result';
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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<ActionMenuConfirmationModalConfig | null>({
|
||||
key: 'actionMenuConfirmationModalConfigState',
|
||||
defaultValue: null,
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type ActionMenuConfirmationModalResult = 'confirm' | 'cancel';
|
||||
|
||||
export type ActionMenuConfirmationModalResultBrowserEventDetail = {
|
||||
frontComponentId: string;
|
||||
confirmationResult: ActionMenuConfirmationModalResult;
|
||||
};
|
||||
|
|
@ -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 = () => {
|
|||
<PageFavicon />
|
||||
<Outlet />
|
||||
<GlobalFilePreviewModal />
|
||||
<ActionMenuConfirmationModalManager />
|
||||
<HeadlessFrontComponentMountRoot />
|
||||
</StrictMode>
|
||||
</DialogManager>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
|||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<React.SetStateAction<RemoteReceiver | null>>;
|
||||
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<ActionMenuConfirmationModalResultBrowserEventDetail>,
|
||||
) => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ActionConfirmationModalResult>((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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export type WorkerExports = {
|
|||
) => Promise<void>;
|
||||
initializeHostCommunicationApi: () => Promise<void>;
|
||||
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
|
||||
onConfirmationModalResult: (result: 'confirm' | 'cancel') => Promise<void>;
|
||||
};
|
||||
|
|
|
|||
78
packages/twenty-sdk/src/sdk/action/ActionModal.tsx
Normal file
78
packages/twenty-sdk/src/sdk/action/ActionModal.tsx
Normal file
|
|
@ -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<void>;
|
||||
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;
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -20,6 +20,17 @@ export type OpenSidePanelPageFunction = (params: {
|
|||
shouldResetSearchState?: boolean;
|
||||
}) => Promise<void>;
|
||||
|
||||
export type ActionConfirmationModalResult = 'confirm' | 'cancel';
|
||||
|
||||
export type ActionConfirmationModalAccent = 'default' | 'blue' | 'danger';
|
||||
|
||||
export type OpenActionConfirmationModalFunction = (params: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonAccent?: ActionConfirmationModalAccent;
|
||||
}) => Promise<ActionConfirmationModalResult>;
|
||||
|
||||
export type UnmountFrontComponentFunction = () => Promise<void>;
|
||||
|
||||
export type EnqueueSnackbarFunction = (
|
||||
|
|
@ -30,10 +41,15 @@ export type CloseSidePanelFunction = () => Promise<void>;
|
|||
|
||||
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
|
||||
|
||||
export type OpenActionConfirmationModalHostFunction = (
|
||||
params: Parameters<OpenActionConfirmationModalFunction>[0],
|
||||
) => Promise<void>;
|
||||
|
||||
export type FrontComponentHostCommunicationApiStore = {
|
||||
navigate?: NavigateFunction;
|
||||
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
|
||||
openSidePanelPage?: OpenSidePanelPageFunction;
|
||||
openActionConfirmationModal?: OpenActionConfirmationModalFunction;
|
||||
unmountFrontComponent?: UnmountFrontComponentFunction;
|
||||
enqueueSnackbar?: EnqueueSnackbarFunction;
|
||||
closeSidePanel?: CloseSidePanelFunction;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue