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:
+89
-6
@@ -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('');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user