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
@@ -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('');
}
};