Headless action modal (#18270)
https://github.com/user-attachments/assets/809a281f-3c38-41df-99db-e780941acf9f
This commit is contained in:
Vendored
+1
@@ -48,6 +48,7 @@
|
|||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/.yarn": true
|
"**/.yarn": true
|
||||||
},
|
},
|
||||||
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.debug": true,
|
"eslint.debug": true,
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
".cursorrules": "markdown"
|
".cursorrules": "markdown"
|
||||||
|
|||||||
+67
@@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export const ACTION_MENU_CONFIRMATION_MODAL_INSTANCE_ID =
|
||||||
|
'action-menu-confirmation-modal';
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export const ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
|
||||||
|
'action-menu-confirmation-modal-result';
|
||||||
+48
@@ -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 };
|
||||||
|
};
|
||||||
+18
@@ -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,
|
||||||
|
});
|
||||||
+6
@@ -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 { AgentChatProvider } from '@/ai/components/AgentChatProvider';
|
||||||
|
import { ActionMenuConfirmationModalManager } from '@/action-menu/confirmation-modal/components/ActionMenuConfirmationModalManager';
|
||||||
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
|
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
|
||||||
import { MetadataGater } from '@/metadata-store/components/MetadataGater';
|
import { MetadataGater } from '@/metadata-store/components/MetadataGater';
|
||||||
import { IsAppMetadataReadyEffect } from '@/metadata-store/effect-components/IsAppMetadataReadyEffect';
|
import { IsAppMetadataReadyEffect } from '@/metadata-store/effect-components/IsAppMetadataReadyEffect';
|
||||||
@@ -71,6 +72,7 @@ export const AppRouterProviders = () => {
|
|||||||
<PageFavicon />
|
<PageFavicon />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<GlobalFilePreviewModal />
|
<GlobalFilePreviewModal />
|
||||||
|
<ActionMenuConfirmationModalManager />
|
||||||
<HeadlessFrontComponentMountRoot />
|
<HeadlessFrontComponentMountRoot />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
</DialogManager>
|
</DialogManager>
|
||||||
|
|||||||
+14
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'twenty-sdk/front-component-renderer';
|
} from 'twenty-sdk/front-component-renderer';
|
||||||
import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
|
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 { currentUserState } from '@/auth/states/currentUserState';
|
||||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||||
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
|
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
|
||||||
@@ -31,6 +32,7 @@ export const useFrontComponentExecutionContext = ({
|
|||||||
const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({
|
const { requestAccessTokenRefresh } = useRequestApplicationTokenRefresh({
|
||||||
frontComponentId,
|
frontComponentId,
|
||||||
});
|
});
|
||||||
|
const { openConfirmationModal } = useActionMenuConfirmationModal();
|
||||||
const { navigateCommandMenu } = useNavigateCommandMenu();
|
const { navigateCommandMenu } = useNavigateCommandMenu();
|
||||||
const setCommandMenuSearch = useSetAtomState(commandMenuSearchState);
|
const setCommandMenuSearch = useSetAtomState(commandMenuSearchState);
|
||||||
const { getIcon } = useIcons();
|
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'] =
|
const enqueueSnackbar: FrontComponentHostCommunicationApi['enqueueSnackbar'] =
|
||||||
async ({
|
async ({
|
||||||
message,
|
message,
|
||||||
@@ -125,6 +138,7 @@ export const useFrontComponentExecutionContext = ({
|
|||||||
navigate,
|
navigate,
|
||||||
requestAccessTokenRefresh,
|
requestAccessTokenRefresh,
|
||||||
openSidePanelPage,
|
openSidePanelPage,
|
||||||
|
openActionConfirmationModal,
|
||||||
enqueueSnackbar,
|
enqueueSnackbar,
|
||||||
unmountFrontComponent,
|
unmountFrontComponent,
|
||||||
closeSidePanel,
|
closeSidePanel,
|
||||||
|
|||||||
+7
-9
@@ -16,6 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
|||||||
args: {
|
args: {
|
||||||
onError: errorHandler,
|
onError: errorHandler,
|
||||||
applicationAccessToken: 'fake-token',
|
applicationAccessToken: 'fake-token',
|
||||||
|
executionContext: { frontComponentId: 'storybook-test', userId: null },
|
||||||
},
|
},
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
errorHandler.mockClear();
|
errorHandler.mockClear();
|
||||||
@@ -117,16 +118,15 @@ export const SdkContext: Story = {
|
|||||||
...createComponentStory('sdk-context-example'),
|
...createComponentStory('sdk-context-example'),
|
||||||
args: {
|
args: {
|
||||||
...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 }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
await canvas.findByTestId(
|
await canvas.findByTestId('sdk-context-component', {}, { timeout: 30000 });
|
||||||
'sdk-context-component',
|
|
||||||
{},
|
|
||||||
{ timeout: 30000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const userIdElement = await canvas.findByTestId('sdk-context-user-id');
|
const userIdElement = await canvas.findByTestId('sdk-context-user-id');
|
||||||
expect(userIdElement).toBeVisible();
|
expect(userIdElement).toBeVisible();
|
||||||
@@ -138,9 +138,7 @@ export const SdkContext: Story = {
|
|||||||
const button = await canvas.findByTestId('sdk-context-button');
|
const button = await canvas.findByTestId('sdk-context-button');
|
||||||
await userEvent.click(button);
|
await userEvent.click(button);
|
||||||
|
|
||||||
const renderCount = await canvas.findByTestId(
|
const renderCount = await canvas.findByTestId('sdk-context-render-count');
|
||||||
'sdk-context-render-count',
|
|
||||||
);
|
|
||||||
expect(renderCount).toHaveTextContent('Renders: 1');
|
expect(renderCount).toHaveTextContent('Renders: 1');
|
||||||
|
|
||||||
expect(userIdElement).toHaveTextContent('test-user-abc-123');
|
expect(userIdElement).toHaveTextContent('test-user-abc-123');
|
||||||
|
|||||||
+5
-4
@@ -16,6 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
|||||||
args: {
|
args: {
|
||||||
onError: errorHandler,
|
onError: errorHandler,
|
||||||
applicationAccessToken: 'fake-token',
|
applicationAccessToken: 'fake-token',
|
||||||
|
executionContext: { frontComponentId: 'storybook-test', userId: null },
|
||||||
},
|
},
|
||||||
beforeEach: () => {
|
beforeEach: () => {
|
||||||
errorHandler.mockClear();
|
errorHandler.mockClear();
|
||||||
@@ -138,7 +139,7 @@ const twentyUiTest: Story['play'] = async ({ canvasElement }) => {
|
|||||||
export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', {
|
export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', {
|
||||||
play: twentyUiTest,
|
play: twentyUiTest,
|
||||||
});
|
});
|
||||||
export const TwentyUiPreact: Story = createComponentStory(
|
export const TwentyUiPreact: Story = createComponentStory('twenty-ui-example', {
|
||||||
'twenty-ui-example',
|
runtime: 'preact',
|
||||||
{ runtime: 'preact', play: twentyUiTest },
|
play: twentyUiTest,
|
||||||
);
|
});
|
||||||
|
|||||||
+2
@@ -50,6 +50,7 @@ export const FrontComponentRenderer = ({
|
|||||||
componentUrl={componentUrl}
|
componentUrl={componentUrl}
|
||||||
applicationAccessToken={applicationAccessToken}
|
applicationAccessToken={applicationAccessToken}
|
||||||
apiUrl={apiUrl}
|
apiUrl={apiUrl}
|
||||||
|
frontComponentId={executionContext.frontComponentId}
|
||||||
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
||||||
setReceiver={setReceiver}
|
setReceiver={setReceiver}
|
||||||
setThread={setThread}
|
setThread={setThread}
|
||||||
@@ -64,6 +65,7 @@ export const FrontComponentRenderer = ({
|
|||||||
setThread,
|
setThread,
|
||||||
applicationAccessToken,
|
applicationAccessToken,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
|
executionContext.frontComponentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+43
@@ -1,14 +1,25 @@
|
|||||||
import { ThreadWebWorker, release, retain } from '@quilted/threads';
|
import { ThreadWebWorker, release, retain } from '@quilted/threads';
|
||||||
import { RemoteReceiver } from '@remote-dom/core/receivers';
|
import { RemoteReceiver } from '@remote-dom/core/receivers';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { type ActionConfirmationModalResult } from '../../../sdk/front-component-api/globals/frontComponentHostCommunicationApi';
|
||||||
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
|
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
|
||||||
import { type WorkerExports } from '../../types/WorkerExports';
|
import { type WorkerExports } from '../../types/WorkerExports';
|
||||||
import { createRemoteWorker } from '../worker/utils/createRemoteWorker';
|
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 = {
|
type FrontComponentWorkerEffectProps = {
|
||||||
componentUrl: string;
|
componentUrl: string;
|
||||||
applicationAccessToken?: string;
|
applicationAccessToken?: string;
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
|
frontComponentId: string;
|
||||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||||
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
|
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
|
||||||
setThread: React.Dispatch<
|
setThread: React.Dispatch<
|
||||||
@@ -24,6 +35,7 @@ export const FrontComponentWorkerEffect = ({
|
|||||||
componentUrl,
|
componentUrl,
|
||||||
applicationAccessToken,
|
applicationAccessToken,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
|
frontComponentId,
|
||||||
frontComponentHostCommunicationApi,
|
frontComponentHostCommunicationApi,
|
||||||
setReceiver,
|
setReceiver,
|
||||||
setThread,
|
setThread,
|
||||||
@@ -55,6 +67,32 @@ export const FrontComponentWorkerEffect = ({
|
|||||||
exports: frontComponentHostCommunicationApi,
|
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);
|
setThread(thread);
|
||||||
|
|
||||||
thread.imports
|
thread.imports
|
||||||
@@ -71,6 +109,10 @@ export const FrontComponentWorkerEffect = ({
|
|||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
ACTION_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
|
||||||
|
handleActionMenuConfirmationModalResultBrowserEvent as EventListener,
|
||||||
|
);
|
||||||
setThread(null);
|
setThread(null);
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
isInitializedRef.current = false;
|
isInitializedRef.current = false;
|
||||||
@@ -79,6 +121,7 @@ export const FrontComponentWorkerEffect = ({
|
|||||||
componentUrl,
|
componentUrl,
|
||||||
applicationAccessToken,
|
applicationAccessToken,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
|
frontComponentId,
|
||||||
setError,
|
setError,
|
||||||
setReceiver,
|
setReceiver,
|
||||||
setThread,
|
setThread,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { type FrontComponentHostCommunicationApi } from '../../types/FrontCompon
|
|||||||
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
|
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
|
||||||
import { type WorkerExports } from '../../types/WorkerExports';
|
import { type WorkerExports } from '../../types/WorkerExports';
|
||||||
import { exposeGlobals } from '../utils/exposeGlobals';
|
import { exposeGlobals } from '../utils/exposeGlobals';
|
||||||
|
import {
|
||||||
|
createOpenActionConfirmationModalAdapter,
|
||||||
|
handleActionConfirmationModalResult,
|
||||||
|
} from './utils/createActionConfirmationModalBridge';
|
||||||
import { setWorkerEnv } from './utils/setWorkerEnv';
|
import { setWorkerEnv } from './utils/setWorkerEnv';
|
||||||
|
|
||||||
installStylePropertyOnRemoteElements();
|
installStylePropertyOnRemoteElements();
|
||||||
@@ -97,6 +101,8 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
|
|||||||
hostApi.requestAccessTokenRefresh;
|
hostApi.requestAccessTokenRefresh;
|
||||||
frontComponentHostCommunicationApi.openSidePanelPage =
|
frontComponentHostCommunicationApi.openSidePanelPage =
|
||||||
hostApi.openSidePanelPage;
|
hostApi.openSidePanelPage;
|
||||||
|
frontComponentHostCommunicationApi.openActionConfirmationModal =
|
||||||
|
createOpenActionConfirmationModalAdapter(hostApi);
|
||||||
frontComponentHostCommunicationApi.unmountFrontComponent =
|
frontComponentHostCommunicationApi.unmountFrontComponent =
|
||||||
hostApi.unmountFrontComponent;
|
hostApi.unmountFrontComponent;
|
||||||
frontComponentHostCommunicationApi.enqueueSnackbar =
|
frontComponentHostCommunicationApi.enqueueSnackbar =
|
||||||
@@ -104,6 +110,11 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
|
|||||||
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
|
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] =
|
||||||
|
async (result) => {
|
||||||
|
await handleActionConfirmationModalResult(result);
|
||||||
|
};
|
||||||
|
|
||||||
const updateContext: WorkerExports['updateContext'] = async (
|
const updateContext: WorkerExports['updateContext'] = async (
|
||||||
context: FrontComponentExecutionContext,
|
context: FrontComponentExecutionContext,
|
||||||
) => {
|
) => {
|
||||||
@@ -113,5 +124,6 @@ const updateContext: WorkerExports['updateContext'] = async (
|
|||||||
ThreadWebWorker.self.export({
|
ThreadWebWorker.self.export({
|
||||||
render,
|
render,
|
||||||
initializeHostCommunicationApi,
|
initializeHostCommunicationApi,
|
||||||
|
onConfirmationModalResult,
|
||||||
updateContext,
|
updateContext,
|
||||||
});
|
});
|
||||||
|
|||||||
+65
@@ -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
@@ -2,6 +2,7 @@ import {
|
|||||||
type CloseSidePanelFunction,
|
type CloseSidePanelFunction,
|
||||||
type EnqueueSnackbarFunction,
|
type EnqueueSnackbarFunction,
|
||||||
type NavigateFunction,
|
type NavigateFunction,
|
||||||
|
type OpenActionConfirmationModalHostFunction,
|
||||||
type OpenSidePanelPageFunction,
|
type OpenSidePanelPageFunction,
|
||||||
type RequestAccessTokenRefreshFunction,
|
type RequestAccessTokenRefreshFunction,
|
||||||
type UnmountFrontComponentFunction,
|
type UnmountFrontComponentFunction,
|
||||||
@@ -11,6 +12,7 @@ export type FrontComponentHostCommunicationApi = {
|
|||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
requestAccessTokenRefresh: RequestAccessTokenRefreshFunction;
|
requestAccessTokenRefresh: RequestAccessTokenRefreshFunction;
|
||||||
openSidePanelPage: OpenSidePanelPageFunction;
|
openSidePanelPage: OpenSidePanelPageFunction;
|
||||||
|
openActionConfirmationModal: OpenActionConfirmationModalHostFunction;
|
||||||
unmountFrontComponent: UnmountFrontComponentFunction;
|
unmountFrontComponent: UnmountFrontComponentFunction;
|
||||||
enqueueSnackbar: EnqueueSnackbarFunction;
|
enqueueSnackbar: EnqueueSnackbarFunction;
|
||||||
closeSidePanel: CloseSidePanelFunction;
|
closeSidePanel: CloseSidePanelFunction;
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export type WorkerExports = {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
initializeHostCommunicationApi: () => Promise<void>;
|
initializeHostCommunicationApi: () => Promise<void>;
|
||||||
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
|
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
|
||||||
|
onConfirmationModalResult: (result: 'confirm' | 'cancel') => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 type { ActionLinkProps } from './ActionLink';
|
||||||
export { ActionOpenSidePanelPage } from './ActionOpenSidePanelPage';
|
export { ActionOpenSidePanelPage } from './ActionOpenSidePanelPage';
|
||||||
export type { ActionOpenSidePanelPageProps } from './ActionOpenSidePanelPage';
|
export type { ActionOpenSidePanelPageProps } from './ActionOpenSidePanelPage';
|
||||||
|
export { ActionModal } from './ActionModal';
|
||||||
|
export type { ActionModalProps } from './ActionModal';
|
||||||
|
|||||||
+18
@@ -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);
|
||||||
|
};
|
||||||
+16
@@ -20,6 +20,17 @@ export type OpenSidePanelPageFunction = (params: {
|
|||||||
shouldResetSearchState?: boolean;
|
shouldResetSearchState?: boolean;
|
||||||
}) => Promise<void>;
|
}) => 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 UnmountFrontComponentFunction = () => Promise<void>;
|
||||||
|
|
||||||
export type EnqueueSnackbarFunction = (
|
export type EnqueueSnackbarFunction = (
|
||||||
@@ -30,10 +41,15 @@ export type CloseSidePanelFunction = () => Promise<void>;
|
|||||||
|
|
||||||
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
|
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
|
||||||
|
|
||||||
|
export type OpenActionConfirmationModalHostFunction = (
|
||||||
|
params: Parameters<OpenActionConfirmationModalFunction>[0],
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export type FrontComponentHostCommunicationApiStore = {
|
export type FrontComponentHostCommunicationApiStore = {
|
||||||
navigate?: NavigateFunction;
|
navigate?: NavigateFunction;
|
||||||
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
|
requestAccessTokenRefresh?: RequestAccessTokenRefreshFunction;
|
||||||
openSidePanelPage?: OpenSidePanelPageFunction;
|
openSidePanelPage?: OpenSidePanelPageFunction;
|
||||||
|
openActionConfirmationModal?: OpenActionConfirmationModalFunction;
|
||||||
unmountFrontComponent?: UnmountFrontComponentFunction;
|
unmountFrontComponent?: UnmountFrontComponentFunction;
|
||||||
enqueueSnackbar?: EnqueueSnackbarFunction;
|
enqueueSnackbar?: EnqueueSnackbarFunction;
|
||||||
closeSidePanel?: CloseSidePanelFunction;
|
closeSidePanel?: CloseSidePanelFunction;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export { setFrontComponentExecutionContext } from './context/frontComponentConte
|
|||||||
export { closeSidePanel } from './functions/closeSidePanel';
|
export { closeSidePanel } from './functions/closeSidePanel';
|
||||||
export { enqueueSnackbar } from './functions/enqueueSnackbar';
|
export { enqueueSnackbar } from './functions/enqueueSnackbar';
|
||||||
export { navigate } from './functions/navigate';
|
export { navigate } from './functions/navigate';
|
||||||
|
export { openActionConfirmationModal } from './functions/openActionConfirmationModal';
|
||||||
export { openSidePanelPage } from './functions/openSidePanelPage';
|
export { openSidePanelPage } from './functions/openSidePanelPage';
|
||||||
export { unmountFrontComponent } from './functions/unmountFrontComponent';
|
export { unmountFrontComponent } from './functions/unmountFrontComponent';
|
||||||
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
|
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
|
||||||
@@ -27,6 +28,10 @@ export { useRecordId } from './hooks/useRecordId';
|
|||||||
export { useUserId } from './hooks/useUserId';
|
export { useUserId } from './hooks/useUserId';
|
||||||
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
|
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
|
||||||
export { getFrontComponentActionErrorDedupeKey } from './utils/getFrontComponentActionErrorDedupeKey';
|
export { getFrontComponentActionErrorDedupeKey } from './utils/getFrontComponentActionErrorDedupeKey';
|
||||||
|
export type {
|
||||||
|
ActionConfirmationModalAccent,
|
||||||
|
ActionConfirmationModalResult,
|
||||||
|
} from './globals/frontComponentHostCommunicationApi';
|
||||||
|
|
||||||
export { ALLOWED_HTML_ELEMENTS } from './constants/AllowedHtmlElements';
|
export { ALLOWED_HTML_ELEMENTS } from './constants/AllowedHtmlElements';
|
||||||
export type { AllowedHtmlElement } 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';
|
export type { ViewConfig } from './views/view-config';
|
||||||
|
|
||||||
// Action components for front components
|
// Action components for front components
|
||||||
export { Action, ActionLink, ActionOpenSidePanelPage } from './action';
|
export {
|
||||||
|
Action,
|
||||||
|
ActionLink,
|
||||||
|
ActionModal,
|
||||||
|
ActionOpenSidePanelPage,
|
||||||
|
} from './action';
|
||||||
export type {
|
export type {
|
||||||
ActionLinkProps,
|
ActionLinkProps,
|
||||||
|
ActionModalProps,
|
||||||
ActionOpenSidePanelPageProps,
|
ActionOpenSidePanelPageProps,
|
||||||
ActionProps,
|
ActionProps,
|
||||||
} from './action';
|
} from './action';
|
||||||
@@ -105,6 +111,7 @@ export {
|
|||||||
enqueueSnackbar,
|
enqueueSnackbar,
|
||||||
getFrontComponentActionErrorDedupeKey,
|
getFrontComponentActionErrorDedupeKey,
|
||||||
navigate,
|
navigate,
|
||||||
|
openActionConfirmationModal,
|
||||||
openSidePanelPage,
|
openSidePanelPage,
|
||||||
unmountFrontComponent,
|
unmountFrontComponent,
|
||||||
useFrontComponentExecutionContext,
|
useFrontComponentExecutionContext,
|
||||||
@@ -112,7 +119,11 @@ export {
|
|||||||
useRecordId,
|
useRecordId,
|
||||||
useUserId,
|
useUserId,
|
||||||
} from './front-component-api';
|
} 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 { AppPath, CommandMenuPages } from 'twenty-shared/types';
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user