feat(apps): let front components open a record in the side panel (#22140)

## Why

Front components (apps) could `navigate()` to a record's **full page**,
but there was no way to open a specific record in the **side panel**.
More generally, `openSidePanelPage` could navigate to a `SidePanelPages`
enum page but couldn't pass the context most pages need.

## What

`openSidePanelPage`'s params are now a **discriminated union keyed on
`page`**, so each page declares its own typed payload (instead of a flat
bag of optionals whose validity silently depends on `page`). This is
also safer: pages that can't render without context can't be "opened"
into a broken panel.

Wired the param-bearing pages host-side, each bridging to its existing
internal hook:

| `page` | Params | Bridges to |
|---|---|---|
| `ViewRecord` | `recordId`, `objectNameSingular`,
`resetNavigationStack?` | `useOpenRecordInSidePanel` (full-page fallback
on mobile / unsupported objects) |
| `EditRichText` | `recordId`, `objectNameSingular`, `fieldName?` |
`useOpenRichTextInSidePanel` |
| `ComposeEmail` | `connectedAccountId`, `threadId?`, `defaultTo?`,
`defaultSubject?`, `defaultInReplyTo?`, `pageTitle?`, `pageIcon?` |
`useOpenComposeEmailInSidePanel` |
| `ViewFrontComponent` | `frontComponentId`, optional
`recordId`+`objectNameSingular`, `pageTitle`, `pageIcon?`,
`resetNavigationStack?` | `useOpenFrontComponentInSidePanel` |
| *(any other page)* | `pageTitle`, `pageIcon?`,
`shouldResetSearchState?` | `navigateSidePanel` |

`CommandOpenSidePanelPage` now takes the union directly, so headless
command-menu items can open any of these. Threaded through `twenty-sdk`
→ `twenty-front-component-renderer` → host
(`useFrontComponentExecutionContext`), with unit tests per page and the
mobile/unsupported fallbacks.

## Deliberately deferred: `MergeRecords`

`useOpenMergeRecordsPageInSidePanel` takes `objectNameSingular` /
`objectRecordIds` at **hook-init** (it calls `useObjectMetadataItem` /
`useLazyFindManyRecords` at render), so it can't be driven by runtime
app params without refactoring that hook + its current caller. Left out
of this PR — better as its own change.

## Worth a second look (reviewers)

- **`ViewFrontComponent`** lets an app open a front component by id.
Within an app that's clean composition; whether an app should be able to
target *another* app's component is a scoping/security question. The
render still runs under the app's access token, so cross-app fetches
would fail auth — but flagging it explicitly.

## Security note

Side-panel record/page views render natively under the **user's**
session/Apollo client, not the app's scoped token — RLS/field
permissions are enforced as if the user opened it themselves. Same trust
model as `navigate(AppPath.RecordShowPage, …)`.

## Follow-up

A separate PR will centralize the mobile + `canOpenObjectInSidePanel`
guard inside `useOpenRecordInSidePanel` (currently duplicated across
callers, missing in others).

## Validation

> [!NOTE]
> Dependencies wouldn't install in this environment (flaky network
during `yarn install`), so lint / typecheck / jest weren't run locally —
relying on CI. The diff was reviewed manually for type-consistency,
including the discriminated-union narrowing in the host switch.

https://claude.ai/code/session_01AAJFXzsCeoj6BeP3ofiTKQ
This commit is contained in:
Félix Malfait
2026-06-25 10:47:39 +02:00
committed by GitHub
parent 885effb3d8
commit 5242ddf458
7 changed files with 344 additions and 49 deletions
@@ -121,7 +121,7 @@ Import them from `twenty-sdk/command`:
- **`Command`** — Runs an async callback via the `execute` prop.
- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`.
- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
- **`CommandOpenSidePanelPage`** — Opens a specific side panel page. Props: `page`, `pageTitle`, `pageIcon`.
- **`CommandOpenSidePanelPage`** — Opens a side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular`, other pages take `pageTitle` + `pageIcon`.
Here is a full example of a headless front component using `Command` to run an action from the command menu:
@@ -381,7 +381,7 @@ The following system variables are always available via `process.env`:
| Variable | Description |
|----------|-------------|
| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from (used by `RestApiClient`) |
| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from |
| `TWENTY_API_URL` | Base URL of the Twenty core API |
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role |
@@ -18,7 +18,8 @@ const HostApiSidePanelOpenFrontComponent = () => {
try {
await openSidePanelPage({
page: SidePanelPages.ViewRecord,
pageTitle: 'Test Record',
recordId: 'test-record-id',
objectNameSingular: 'company',
});
setStatus('sidePanel:success');
} catch (error) {
@@ -2,7 +2,7 @@ import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { act, renderHook } from '@testing-library/react';
import { getDefaultStore } from 'jotai';
import { AppPath } from 'twenty-shared/types';
import { AppPath, SidePanelPages } from 'twenty-shared/types';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
@@ -12,6 +12,10 @@ const mockNavigateApp = jest.fn();
const mockRequestAccessTokenRefresh = jest.fn();
const mockOpenConfirmationModal = jest.fn();
const mockNavigateSidePanel = jest.fn();
const mockOpenRecordInSidePanel = jest.fn();
const mockOpenRichTextInSidePanel = jest.fn();
const mockOpenComposeEmailInSidePanel = jest.fn();
const mockOpenFrontComponentInSidePanel = jest.fn();
const mockSetSidePanelSearch = jest.fn();
const mockGetIcon = jest.fn((name: string) => `icon-${name}`);
const mockUnmountEngineCommand = jest.fn();
@@ -24,6 +28,7 @@ const mockSetCommandMenuItemProgress = jest.fn();
const mockCopyToClipboard = jest.fn();
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
let mockIsMobile = false;
jest.mock('~/hooks/useNavigateApp', () => ({
useNavigateApp: () => mockNavigateApp,
@@ -50,6 +55,30 @@ jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({
}),
}));
jest.mock('@/side-panel/hooks/useOpenRecordInSidePanel', () => ({
useOpenRecordInSidePanel: () => ({
openRecordInSidePanel: mockOpenRecordInSidePanel,
}),
}));
jest.mock('@/side-panel/hooks/useOpenRichTextInSidePanel', () => ({
useOpenRichTextInSidePanel: () => ({
openRichTextInSidePanel: mockOpenRichTextInSidePanel,
}),
}));
jest.mock('@/side-panel/hooks/useOpenComposeEmailInSidePanel', () => ({
useOpenComposeEmailInSidePanel: () => ({
openComposeEmailInSidePanel: mockOpenComposeEmailInSidePanel,
}),
}));
jest.mock('@/side-panel/hooks/useOpenFrontComponentInSidePanel', () => ({
useOpenFrontComponentInSidePanel: () => ({
openFrontComponentInSidePanel: mockOpenFrontComponentInSidePanel,
}),
}));
jest.mock(
'@/command-menu-item/engine-command/hooks/useUnmountEngineCommand',
() => ({
@@ -78,6 +107,10 @@ jest.mock('twenty-ui/icon', () => ({
}),
}));
jest.mock('twenty-ui/utilities', () => ({
useIsMobile: () => mockIsMobile,
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: () => mockCurrentUser,
}));
@@ -130,6 +163,7 @@ describe('useFrontComponentExecutionContext', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCurrentUser = { id: 'user-123' };
mockIsMobile = false;
getDefaultStore().set(parentViewAtom, undefined);
});
@@ -334,6 +368,166 @@ describe('useFrontComponentExecutionContext', () => {
});
});
describe('openSidePanelPage with a record context', () => {
it('should open the record in the side panel when the object is supported', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.ViewRecord,
recordId: 'lead-1',
objectNameSingular: 'lead',
resetNavigationStack: true,
},
);
});
expect(mockOpenRecordInSidePanel).toHaveBeenCalledWith({
recordId: 'lead-1',
objectNameSingular: 'lead',
resetNavigationStack: true,
});
expect(mockNavigateApp).not.toHaveBeenCalled();
expect(mockNavigateSidePanel).not.toHaveBeenCalled();
});
it('should fall back to full-page navigation on mobile', async () => {
mockIsMobile = true;
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.ViewRecord,
recordId: 'lead-1',
objectNameSingular: 'lead',
},
);
});
expect(mockNavigateApp).toHaveBeenCalledWith(
AppPath.RecordShowPage,
{ objectNameSingular: 'lead', objectRecordId: 'lead-1' },
undefined,
undefined,
);
expect(mockOpenRecordInSidePanel).not.toHaveBeenCalled();
});
it('should fall back to full-page navigation when the object cannot open in the side panel', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.ViewRecord,
recordId: 'workflow-1',
objectNameSingular: 'workflow',
},
);
});
expect(mockNavigateApp).toHaveBeenCalledWith(
AppPath.RecordShowPage,
{ objectNameSingular: 'workflow', objectRecordId: 'workflow-1' },
undefined,
undefined,
);
expect(mockOpenRecordInSidePanel).not.toHaveBeenCalled();
});
});
describe('openSidePanelPage with EditRichText', () => {
it('should open the rich text editor for a record field', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.EditRichText,
recordId: 'note-1',
objectNameSingular: 'note',
fieldName: 'body',
},
);
});
expect(mockOpenRichTextInSidePanel).toHaveBeenCalledWith(
'note-1',
'note',
'body',
);
});
});
describe('openSidePanelPage with ComposeEmail', () => {
it('should open the email composer with the provided params', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.ComposeEmail,
connectedAccountId: 'account-1',
defaultTo: 'lead@example.com',
pageIcon: 'IconMail',
},
);
});
expect(mockOpenComposeEmailInSidePanel).toHaveBeenCalledWith({
connectedAccountId: 'account-1',
threadId: undefined,
defaultTo: 'lead@example.com',
defaultSubject: undefined,
defaultInReplyTo: undefined,
pageTitle: undefined,
pageIcon: 'icon-IconMail',
});
});
});
describe('openSidePanelPage with ViewFrontComponent', () => {
it('should open a front component with optional record context', async () => {
const { result } = renderUseFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
});
await act(async () => {
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
{
page: SidePanelPages.ViewFrontComponent,
frontComponentId: 'fc-1',
pageTitle: 'My Component',
pageIcon: 'IconBolt',
recordId: 'lead-1',
objectNameSingular: 'lead',
},
);
});
expect(mockOpenFrontComponentInSidePanel).toHaveBeenCalledWith({
frontComponentId: 'fc-1',
pageTitle: 'My Component',
pageIcon: 'icon-IconBolt',
resetNavigationStack: undefined,
recordContext: { recordId: 'lead-1', objectNameSingular: 'lead' },
});
});
});
describe('openCommandConfirmationModal', () => {
it('should call openConfirmationModal with frontComponent caller', async () => {
const { result } = renderUseFrontComponentExecutionContext({
@@ -6,7 +6,11 @@ import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
} from 'twenty-front-component-renderer';
import { AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
import {
AppPath,
SidePanelPages,
type EnqueueSnackbarParams,
} from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
@@ -15,7 +19,12 @@ import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/c
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useOpenRichTextInSidePanel } from '@/side-panel/hooks/useOpenRichTextInSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -24,6 +33,7 @@ import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAt
import { useStore } from 'jotai';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { useIsMobile } from 'twenty-ui/utilities';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -53,6 +63,12 @@ export const useFrontComponentExecutionContext = ({
});
const { openConfirmationModal } = useCommandMenuConfirmationModal();
const { navigateSidePanel } = useNavigateSidePanel();
const { openRecordInSidePanel: openRecordInSidePanelInternal } =
useOpenRecordInSidePanel();
const { openRichTextInSidePanel } = useOpenRichTextInSidePanel();
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const isMobile = useIsMobile();
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
const { getIcon } = useIcons();
const unmountEngineCommand = useUnmountCommand();
@@ -108,14 +124,81 @@ export const useFrontComponentExecutionContext = ({
};
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
async ({ page, pageTitle, pageIcon, shouldResetSearchState }) => {
async (params) => {
if (params.page === SidePanelPages.ViewRecord) {
const { recordId, objectNameSingular, resetNavigationStack } = params;
if (isMobile || !canOpenObjectInSidePanel(objectNameSingular)) {
await navigate(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId: recordId,
});
return;
}
openRecordInSidePanelInternal({
recordId,
objectNameSingular,
resetNavigationStack,
});
return;
}
if (params.page === SidePanelPages.EditRichText) {
openRichTextInSidePanel(
params.recordId,
params.objectNameSingular,
params.fieldName,
);
return;
}
if (params.page === SidePanelPages.ComposeEmail) {
openComposeEmailInSidePanel({
connectedAccountId: params.connectedAccountId,
threadId: params.threadId,
defaultTo: params.defaultTo,
defaultSubject: params.defaultSubject,
defaultInReplyTo: params.defaultInReplyTo,
pageTitle: params.pageTitle,
pageIcon: isDefined(params.pageIcon)
? getIcon(params.pageIcon)
: undefined,
});
return;
}
if (params.page === SidePanelPages.ViewFrontComponent) {
const recordContext =
isDefined(params.recordId) && isDefined(params.objectNameSingular)
? {
recordId: params.recordId,
objectNameSingular: params.objectNameSingular,
}
: undefined;
openFrontComponentInSidePanel({
frontComponentId: params.frontComponentId,
pageTitle: params.pageTitle,
pageIcon: getIcon(params.pageIcon),
resetNavigationStack: params.resetNavigationStack,
recordContext,
});
return;
}
navigateSidePanel({
page,
pageTitle,
pageIcon: getIcon(pageIcon),
page: params.page,
pageTitle: params.pageTitle,
pageIcon: getIcon(params.pageIcon),
});
if (shouldResetSearchState === true) {
if (params.shouldResetSearchState === true) {
setSidePanelSearch('');
}
};
@@ -1,27 +1,16 @@
import {
openSidePanelPage,
type OpenSidePanelPageParams,
unmountFrontComponent,
useFrontComponentId,
} from '@/sdk/front-component';
import { useEffect, useState } from 'react';
import { type SidePanelPages } from 'twenty-shared/types';
export type CommandOpenSidePanelPageProps = OpenSidePanelPageParams;
export type CommandOpenSidePanelPageProps = {
page: SidePanelPages;
pageTitle: string;
pageIcon: string;
onClick?: () => void;
shouldResetSearchState?: boolean;
};
export const CommandOpenSidePanelPage = ({
page,
pageTitle,
pageIcon,
onClick,
shouldResetSearchState = false,
}: CommandOpenSidePanelPageProps) => {
export const CommandOpenSidePanelPage = (
props: CommandOpenSidePanelPageProps,
) => {
const [hasExecuted, setHasExecuted] = useState(false);
const frontComponentId = useFrontComponentId();
@@ -34,28 +23,13 @@ export const CommandOpenSidePanelPage = ({
setHasExecuted(true);
const run = async () => {
onClick?.();
await openSidePanelPage({
page,
pageTitle,
pageIcon,
shouldResetSearchState,
});
await openSidePanelPage(props);
await unmountFrontComponent();
};
run();
}, [
page,
pageTitle,
pageIcon,
shouldResetSearchState,
onClick,
hasExecuted,
frontComponentId,
]);
}, [props, hasExecuted, frontComponentId]);
return null;
};
@@ -13,12 +13,54 @@ export type NavigateFunction = <T extends AppPath>(
options?: NavigateOptions,
) => Promise<void>;
export type OpenSidePanelPageFunction = (params: {
page: SidePanelPages;
pageTitle: string;
pageIcon?: string;
shouldResetSearchState?: boolean;
}) => Promise<void>;
export type OpenSidePanelPageParams =
| {
page: SidePanelPages.ViewRecord;
recordId: string;
objectNameSingular: string;
resetNavigationStack?: boolean;
}
| {
page: SidePanelPages.EditRichText;
recordId: string;
objectNameSingular: string;
fieldName?: string;
}
| {
page: SidePanelPages.ComposeEmail;
connectedAccountId: string;
threadId?: string;
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
pageTitle?: string;
pageIcon?: string;
}
| {
page: SidePanelPages.ViewFrontComponent;
frontComponentId: string;
recordId?: string;
objectNameSingular?: string;
pageTitle: string;
pageIcon?: string;
resetNavigationStack?: boolean;
}
| {
page: Exclude<
SidePanelPages,
| SidePanelPages.ViewRecord
| SidePanelPages.EditRichText
| SidePanelPages.ComposeEmail
| SidePanelPages.ViewFrontComponent
>;
pageTitle: string;
pageIcon?: string;
shouldResetSearchState?: boolean;
};
export type OpenSidePanelPageFunction = (
params: OpenSidePanelPageParams,
) => Promise<void>;
export type CommandConfirmationModalResult = 'confirm' | 'cancel';
@@ -25,6 +25,7 @@ export type {
OpenCommandConfirmationModalFunction,
OpenCommandConfirmationModalHostFunction,
OpenSidePanelPageFunction,
OpenSidePanelPageParams,
RequestAccessTokenRefreshFunction,
UnmountFrontComponentFunction,
UpdateProgressFunction,