[FRONT COMPONENTS] Add snackbar to the API (#18136)

## PR description

- Adds `enqueueSnackbar` to the front component host communication API,
allowing front components to display snack bar notifications (success,
error, info, warning) to the user.
- Introduces `frontComponentId` to the `FrontComponentExecutionContext`
and a `useFrontComponentId` hook, enabling front components to identify
themselves (used for dedupe keys).
- Wires error/success notifications into the `Action`, `ActionLink`, and
`ActionOpenSidePanelPage` SDK components -> actions now catch errors and
display them as snack bars, and `Action` supports an optional
`notifyOnEnd` prop for success feedback.

## Video QA

### Success example


https://github.com/user-attachments/assets/8cc53d31-d9eb-49a8-9220-f7866ec1b415

### Error example


https://github.com/user-attachments/assets/a37d65b8-0b5f-4adb-bcdb-571bb2b997c3

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-02-23 12:25:05 +01:00
committed by GitHub
parent 7cbbd082e3
commit a0c0e7de25
24 changed files with 203 additions and 28 deletions
+2
View File
@@ -23,6 +23,8 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
env:
NODE_OPTIONS: '--max-old-space-size=4096'
strategy:
matrix:
task: [lint, typecheck, test]
@@ -10,7 +10,7 @@ export const ActionListItem = ({
action,
onClick,
to,
disabled,
disabled = false,
}: {
action: ActionDisplayProps;
onClick?: () => void;
@@ -3,14 +3,16 @@ import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
} from 'twenty-sdk/front-component-renderer';
import { type AppPath } from 'twenty-shared/types';
import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { assertUnreachable } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -28,6 +30,12 @@ export const useFrontComponentExecutionContext = ({
const setCommandMenuSearchState = useSetRecoilState(commandMenuSearchState);
const { getIcon } = useIcons();
const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent();
const {
enqueueSuccessSnackBar,
enqueueErrorSnackBar,
enqueueInfoSnackBar,
enqueueWarningSnackBar,
} = useSnackBar();
const { closeCommandMenu } = useCommandMenu();
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
@@ -57,7 +65,40 @@ export const useFrontComponentExecutionContext = ({
}
};
const enqueueSnackbar: FrontComponentHostCommunicationApi['enqueueSnackbar'] =
async ({
message,
variant,
duration,
detailedMessage,
dedupeKey,
}: EnqueueSnackbarParams) => {
const snackBarOptions = {
duration,
detailedMessage,
dedupeKey,
};
switch (variant) {
case 'error':
enqueueErrorSnackBar({ message, options: snackBarOptions });
break;
case 'info':
enqueueInfoSnackBar({ message, options: snackBarOptions });
break;
case 'warning':
enqueueWarningSnackBar({ message, options: snackBarOptions });
break;
case 'success':
enqueueSuccessSnackBar({ message, options: snackBarOptions });
break;
default:
assertUnreachable(variant);
}
};
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
};
@@ -75,6 +116,7 @@ export const useFrontComponentExecutionContext = ({
{
navigate,
openSidePanelPage,
enqueueSnackbar,
unmountFrontComponent,
closeSidePanel,
};
@@ -1,9 +1,9 @@
import { FrontComponentErrorEffect } from '@/front-component-renderer/remote/components/FrontComponentErrorEffect';
import { FrontComponentHostCommunicationApiEffect } from '@/front-component-renderer/remote/components/FrontComponentHostCommunicationApiEffect';
import { FrontComponentUpdateContextEffect } from '@/front-component-renderer/remote/components/FrontComponentUpdateContextEffect';
import { type FrontComponentExecutionContext } from '@/front-component-renderer/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component-renderer/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component-renderer/types/WorkerExports';
import { type FrontComponentExecutionContext } from '@/sdk/front-component-api';
import { type ThreadWebWorker } from '@quilted/threads';
import {
type RemoteReceiver,
@@ -42,6 +42,8 @@ export const FrontComponentRenderer = ({
FrontComponentHostCommunicationApi
> | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isExecutionContextInitialized, setIsExecutionContextInitialized] =
useState(false);
const MemoizedFrontComponentWorkerEffect = useMemo(() => {
return (
@@ -98,11 +100,14 @@ export const FrontComponentRenderer = ({
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
onExecutionContextInitialized={() =>
setIsExecutionContextInitialized(true)
}
/>
</>
)}
{isDefined(receiver) && (
{isDefined(receiver) && isExecutionContextInitialized && (
<ThemeProvider theme={theme}>
<RemoteRootRenderer
receiver={receiver}
@@ -112,7 +112,7 @@ export type {
HtmlThProperties,
} from './remote/generated/remote-elements';
export { createRemoteWorker } from './remote/worker/utils/createRemoteWorker';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export type { FrontComponentExecutionContext } from '../sdk/front-component-api';
export type { FrontComponentHostCommunicationApi } from './types/FrontComponentHostCommunicationApi';
export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext';
export type { PropertySchema } from './types/PropertySchema';
@@ -1,21 +1,28 @@
import { type FrontComponentExecutionContext } from '@/front-component-renderer/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component-renderer/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component-renderer/types/WorkerExports';
import { type FrontComponentExecutionContext } from '@/sdk/front-component-api';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentUpdateContextEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
executionContext: FrontComponentExecutionContext;
onExecutionContextInitialized: () => void;
};
export const FrontComponentUpdateContextEffect = ({
thread,
executionContext,
onExecutionContextInitialized,
}: FrontComponentUpdateContextEffectProps) => {
useEffect(() => {
thread.imports.updateContext(executionContext).catch(() => {});
}, [executionContext, thread]);
const updateContext = async () => {
await thread.imports.updateContext(executionContext).catch(() => {});
onExecutionContextInitialized();
};
updateContext();
}, [executionContext, onExecutionContextInitialized, thread]);
return null;
};
@@ -19,7 +19,7 @@ import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/sdk/front-component-api/consta
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
import { frontComponentHostCommunicationApi } from '@/sdk/front-component-api/globals/frontComponentHostCommunicationApi';
import { type FrontComponentExecutionContext } from '../../types/FrontComponentExecutionContext';
import { type FrontComponentExecutionContext } from '@/sdk/front-component-api';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
@@ -95,6 +95,8 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
hostApi.openSidePanelPage;
frontComponentHostCommunicationApi.unmountFrontComponent =
hostApi.unmountFrontComponent;
frontComponentHostCommunicationApi.enqueueSnackbar =
hostApi.enqueueSnackbar;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
};
@@ -1,3 +1,4 @@
export type FrontComponentExecutionContext = {
frontComponentId: string;
userId: string | null;
};
@@ -1,5 +1,6 @@
import {
type CloseSidePanelFunction,
type EnqueueSnackbarFunction,
type NavigateFunction,
type OpenSidePanelPageFunction,
type UnmountFrontComponentFunction,
@@ -9,5 +10,6 @@ export type FrontComponentHostCommunicationApi = {
navigate: NavigateFunction;
openSidePanelPage: OpenSidePanelPageFunction;
unmountFrontComponent: UnmountFrontComponentFunction;
enqueueSnackbar: EnqueueSnackbarFunction;
closeSidePanel: CloseSidePanelFunction;
};
+18 -2
View File
@@ -1,6 +1,11 @@
import { useEffect, useState } from 'react';
import { unmountFrontComponent } from '../front-component-api';
import {
enqueueSnackbar,
getFrontComponentActionErrorDedupeKey,
unmountFrontComponent,
useFrontComponentId,
} from '../front-component-api';
export type ActionProps = {
execute: () => void | Promise<void>;
@@ -9,6 +14,8 @@ export type ActionProps = {
export const Action = ({ execute }: ActionProps) => {
const [hasExecuted, setHasExecuted] = useState(false);
const frontComponentId = useFrontComponentId();
useEffect(() => {
if (hasExecuted) {
return;
@@ -19,13 +26,22 @@ export const Action = ({ execute }: ActionProps) => {
const run = async () => {
try {
await execute();
} catch (error) {
if (error instanceof Error) {
await enqueueSnackbar({
message: 'Action failed',
detailedMessage: error.message,
variant: 'error',
dedupeKey: getFrontComponentActionErrorDedupeKey(frontComponentId),
});
}
} finally {
await unmountFrontComponent();
}
};
run();
}, [execute, hasExecuted]);
}, [execute, hasExecuted, frontComponentId]);
return null;
};
@@ -3,7 +3,13 @@ import { useEffect, useState } from 'react';
import { type NavigateOptions } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { type getAppPath } from 'twenty-shared/utils';
import { navigate, unmountFrontComponent } from '../front-component-api';
import {
enqueueSnackbar,
getFrontComponentActionErrorDedupeKey,
navigate,
unmountFrontComponent,
useFrontComponentId,
} from '../front-component-api';
export type ActionLinkProps<T extends AppPath> = {
to: T;
@@ -20,6 +26,8 @@ export const ActionLink = <T extends AppPath>({
}: ActionLinkProps<T>) => {
const [hasExecuted, setHasExecuted] = useState(false);
const frontComponentId = useFrontComponentId();
useEffect(() => {
if (hasExecuted) {
return;
@@ -30,13 +38,22 @@ export const ActionLink = <T extends AppPath>({
const run = async () => {
try {
await navigate(to, params, queryParams, options);
} catch (error) {
if (error instanceof Error) {
await enqueueSnackbar({
message: 'Action failed',
detailedMessage: error.message,
variant: 'error',
dedupeKey: getFrontComponentActionErrorDedupeKey(frontComponentId),
});
}
} finally {
await unmountFrontComponent();
}
};
run();
}, [to, params, queryParams, options, hasExecuted]);
}, [to, params, queryParams, options, hasExecuted, frontComponentId]);
return null;
};
@@ -1,6 +1,9 @@
import {
enqueueSnackbar,
getFrontComponentActionErrorDedupeKey,
openSidePanelPage,
unmountFrontComponent,
useFrontComponentId,
} from '@/sdk/front-component-api';
import { useEffect, useState } from 'react';
@@ -23,6 +26,8 @@ export const ActionOpenSidePanelPage = ({
}: ActionOpenSidePanelPageProps) => {
const [hasExecuted, setHasExecuted] = useState(false);
const frontComponentId = useFrontComponentId();
useEffect(() => {
if (hasExecuted) {
return;
@@ -40,13 +45,30 @@ export const ActionOpenSidePanelPage = ({
pageIcon,
shouldResetSearchState,
});
} catch (error) {
if (error instanceof Error) {
await enqueueSnackbar({
message: 'Action failed',
detailedMessage: error.message,
variant: 'error',
dedupeKey: getFrontComponentActionErrorDedupeKey(frontComponentId),
});
}
} finally {
await unmountFrontComponent();
}
};
run();
}, [page, pageTitle, pageIcon, shouldResetSearchState, onClick, hasExecuted]);
}, [
page,
pageTitle,
pageIcon,
shouldResetSearchState,
onClick,
hasExecuted,
frontComponentId,
]);
return null;
};
@@ -2,8 +2,6 @@ import { type FrontComponentExecutionContext } from '../types/FrontComponentExec
type Listener = () => void;
// State is stored on globalThis so the worker's SDK instance and each
// front component's bundled SDK copy share the same backing store.
const CONTEXT_KEY = '__twentySdkExecutionContext__';
const LISTENERS_KEY = '__twentySdkContextListeners__';
@@ -28,13 +26,12 @@ export const setFrontComponentExecutionContext = (
}
};
export const getFrontComponentExecutionContext = ():
| FrontComponentExecutionContext
| undefined => {
return (globalThis as Record<string, unknown>)[CONTEXT_KEY] as
| FrontComponentExecutionContext
| undefined;
};
export const getFrontComponentExecutionContext =
(): FrontComponentExecutionContext => {
return (globalThis as Record<string, unknown>)[
CONTEXT_KEY
] as FrontComponentExecutionContext;
};
export const subscribeToFrontComponentExecutionContext = (
listener: Listener,
@@ -0,0 +1,20 @@
import { type EnqueueSnackbarParams } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
frontComponentHostCommunicationApi,
type EnqueueSnackbarFunction,
} from '../globals/frontComponentHostCommunicationApi';
export const enqueueSnackbar: EnqueueSnackbarFunction = (
params: EnqueueSnackbarParams,
) => {
const enqueueSnackbarFunction =
frontComponentHostCommunicationApi.enqueueSnackbar;
if (!isDefined(enqueueSnackbarFunction)) {
throw new Error('enqueueSnackbarFunction is not set');
}
return enqueueSnackbarFunction(params);
};
@@ -1,6 +1,7 @@
import {
type AppPath,
type CommandMenuPages,
type EnqueueSnackbarParams,
type NavigateOptions,
} from 'twenty-shared/types';
import { type getAppPath } from 'twenty-shared/utils';
@@ -21,12 +22,17 @@ export type OpenSidePanelPageFunction = (params: {
export type UnmountFrontComponentFunction = () => Promise<void>;
export type EnqueueSnackbarFunction = (
params: EnqueueSnackbarParams,
) => Promise<void>;
export type CloseSidePanelFunction = () => Promise<void>;
export type FrontComponentHostCommunicationApiStore = {
navigate?: NavigateFunction;
openSidePanelPage?: OpenSidePanelPageFunction;
unmountFrontComponent?: UnmountFrontComponentFunction;
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
};
@@ -7,7 +7,7 @@ import {
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
export const useFrontComponentExecutionContext = <T>(
selector: (context: FrontComponentExecutionContext | undefined) => T,
selector: (context: FrontComponentExecutionContext) => T,
): T => {
const [currentSelectedValue, setCurrentSelectedValue] = useState(() =>
selector(getFrontComponentExecutionContext()),
@@ -0,0 +1,10 @@
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext';
const selectFrontComponentId = (
context: FrontComponentExecutionContext,
): string => context.frontComponentId;
export const useFrontComponentId = (): string => {
return useFrontComponentExecutionContext(selectFrontComponentId);
};
@@ -1,10 +1,9 @@
import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext';
import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext';
const selectUserId = (
context: FrontComponentExecutionContext | undefined,
): string | null | undefined => context?.userId;
const selectUserId = (context: FrontComponentExecutionContext): string | null =>
context.userId;
export const useUserId = (): string | null | undefined => {
export const useUserId = (): string | null => {
return useFrontComponentExecutionContext(selectUserId);
};
@@ -1,11 +1,14 @@
export { setFrontComponentExecutionContext } from './context/frontComponentContext';
export { closeSidePanel } from './functions/closeSidePanel';
export { enqueueSnackbar } from './functions/enqueueSnackbar';
export { navigate } from './functions/navigate';
export { openSidePanelPage } from './functions/openSidePanelPage';
export { unmountFrontComponent } from './functions/unmountFrontComponent';
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
export { useFrontComponentId } from './hooks/useFrontComponentId';
export { useUserId } from './hooks/useUserId';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export { getFrontComponentActionErrorDedupeKey } from './utils/getFrontComponentActionErrorDedupeKey';
export { ALLOWED_HTML_ELEMENTS } from './constants/AllowedHtmlElements';
export type { AllowedHtmlElement } from './constants/AllowedHtmlElements';
@@ -1,3 +1,4 @@
export type FrontComponentExecutionContext = {
frontComponentId: string;
userId: string | null;
};
@@ -0,0 +1,3 @@
export const getFrontComponentActionErrorDedupeKey = (
frontComponentId: string,
): string => `${frontComponentId}-action-error`;
+7
View File
@@ -74,16 +74,23 @@ export type { ActionOpenSidePanelPageProps } from './action';
// Front Component API exports
export {
enqueueSnackbar,
getFrontComponentActionErrorDedupeKey,
closeSidePanel,
navigate,
openSidePanelPage,
unmountFrontComponent,
useFrontComponentExecutionContext,
useFrontComponentId,
useUserId,
} from './front-component-api';
export type { FrontComponentExecutionContext } from './front-component-api';
export { AppPath, CommandMenuPages } from 'twenty-shared/types';
export type {
EnqueueSnackbarParams,
SnackBarVariant,
} from 'twenty-shared/types';
// Front Component Common exports
export {
@@ -0,0 +1,9 @@
export type SnackBarVariant = 'error' | 'success' | 'info' | 'warning';
export type EnqueueSnackbarParams = {
message: string;
variant: SnackBarVariant;
duration?: number;
detailedMessage?: string;
dedupeKey?: string;
};
@@ -53,6 +53,10 @@ export type { CompositeFieldSubFieldName } from './CompositeFieldSubFieldNameTyp
export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export { CrudOperationType } from './CrudOperationType';
export type {
SnackBarVariant,
EnqueueSnackbarParams,
} from './EnqueueSnackbarParams';
export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
export { EventLogTable } from './EventLogTable';
export type { ExcludeFunctions } from './ExcludeFunctions';