Add tab param to open a record side panel page on a specific tab (#22905)
## Context
`CommandOpenSidePanelPage` (and `openSidePanelPage` in the front
component SDK) could open a record in the side panel, but always landed
on the default tab. This adds an optional `tab` param to the
`ViewRecord` page params so an app command can open a record directly on
a specific tab.
## What changed
- **twenty-sdk**: `OpenSidePanelPageParams` `ViewRecord` variant accepts
an optional `tab` (a page layout tab id). Since
`CommandOpenSidePanelPage` props are `OpenSidePanelPageParams`, the
component picks it up automatically.
- **twenty-front**:
- New `setRecordPageActiveTabId` util resolves the record page layout
for the object (custom layout from the store, or the default layout id)
and presets `activeTabIdComponentState` on the tab list instance
(`${pageLayoutId}-tab-list-${recordId}`), which is shared by the side
panel and the full record page.
- `useOpenRecordInSidePanel` accepts `tab` and presets the active tab
before navigating; it also applies when the record is already open in
the side panel (tab switch only).
- `useFrontComponentExecutionContext` forwards `tab` to the side panel
open, and presets the tab when falling back to full-page navigation
(mobile, or objects that can't open in the side panel).
- **Docs**: mention the optional `tab` id in the
`CommandOpenSidePanelPage` description.
Unknown tab ids are harmless: `PageLayoutTabListEffect` falls back to
the layout's default tab when the preset id doesn't exist in the layout.
Dashboards are skipped since their layout id comes from record data, not
object metadata.
## Tests
- `useOpenRecordInSidePanel`: new test asserting the active tab atom is
preset on the correct tab list instance id.
- `useFrontComponentExecutionContext`: new tests for tab passthrough to
the side panel and tab preset on full-page fallback.
- `npx nx typecheck twenty-front`, `typecheck twenty-sdk`,
`lint:diff-with-main twenty-front`, `lint twenty-sdk` all green.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VaWY1H9RZqgJkytqZakvZi)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22905?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -120,7 +120,7 @@ Import them from `twenty-sdk/front-component`:
|
||||
- **`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 side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular`, other pages take `pageTitle` + `pageIcon`.
|
||||
- **`CommandOpenSidePanelPage`** — Opens a side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular` (plus an optional `tab` id to open the record on a specific tab), 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:
|
||||
|
||||
@@ -194,6 +194,44 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
And an example using `CommandOpenSidePanelPage` to open the current record in the side panel on a specific tab. `tab` is a page layout tab id (default layouts use ids like `company-tab-emails` or `company-tab-timeline`; custom layouts use the tab's own id). If the id doesn't exist in the record's layout, the default tab opens instead:
|
||||
|
||||
```tsx src/front-components/open-company-emails.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
CommandOpenSidePanelPage,
|
||||
SidePanelPages,
|
||||
useSelectedRecordIds,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
const OpenCompanyEmails = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
|
||||
if (!recordId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandOpenSidePanelPage
|
||||
page={SidePanelPages.ViewRecord}
|
||||
recordId={recordId}
|
||||
objectNameSingular="company"
|
||||
tab="company-tab-emails"
|
||||
resetNavigationStack={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||||
name: 'open-company-emails',
|
||||
description: 'Opens the current company on its Emails tab',
|
||||
component: OpenCompanyEmails,
|
||||
isHeadless: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Calling a logic function
|
||||
|
||||
Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
|
||||
|
||||
+63
@@ -27,6 +27,7 @@ const mockEnqueueWarningSnackBar = jest.fn();
|
||||
const mockCloseSidePanelMenu = jest.fn();
|
||||
const mockSetCommandMenuItemProgress = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockSetRecordPageActiveTabId = jest.fn();
|
||||
|
||||
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
|
||||
let mockIsMobile = false;
|
||||
@@ -130,6 +131,11 @@ jest.mock('~/hooks/useCopyToClipboard', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/page-layout/utils/setRecordPageActiveTabId', () => ({
|
||||
setRecordPageActiveTabId: (params: unknown) =>
|
||||
mockSetRecordPageActiveTabId(params),
|
||||
}));
|
||||
|
||||
const renderUseFrontComponentExecutionContext = (
|
||||
params: Omit<
|
||||
Parameters<typeof useFrontComponentExecutionContext>[0],
|
||||
@@ -397,6 +403,63 @@ describe('useFrontComponentExecutionContext', () => {
|
||||
expect(mockNavigateSidePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should forward the tab to the side panel record page', async () => {
|
||||
const { result } = renderUseFrontComponentExecutionContext({
|
||||
frontComponentId: FRONT_COMPONENT_ID,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.frontComponentHostCommunicationApi.openSidePanelPage(
|
||||
{
|
||||
page: SidePanelPages.ViewRecord,
|
||||
recordId: 'lead-1',
|
||||
objectNameSingular: 'lead',
|
||||
tab: 'tab-emails',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOpenRecordInSidePanel).toHaveBeenCalledWith({
|
||||
recordId: 'lead-1',
|
||||
objectNameSingular: 'lead',
|
||||
tab: 'tab-emails',
|
||||
resetNavigationStack: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the record page active tab when falling back to full-page navigation', 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',
|
||||
tab: 'tab-emails',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockSetRecordPageActiveTabId).toHaveBeenCalledWith({
|
||||
recordId: 'lead-1',
|
||||
objectNameSingular: 'lead',
|
||||
tabId: 'tab-emails',
|
||||
store: expect.anything(),
|
||||
});
|
||||
expect(mockNavigateApp).toHaveBeenCalledWith(
|
||||
AppPath.RecordShowPage,
|
||||
{ objectNameSingular: 'lead', objectRecordId: 'lead-1' },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockOpenRecordInSidePanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to full-page navigation on mobile', async () => {
|
||||
mockIsMobile = true;
|
||||
|
||||
|
||||
+13
-1
@@ -28,6 +28,7 @@ import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFron
|
||||
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
|
||||
import { useOpenRichTextInSidePanel } from '@/side-panel/hooks/useOpenRichTextInSidePanel';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { setRecordPageActiveTabId } from '@/page-layout/utils/setRecordPageActiveTabId';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -129,9 +130,19 @@ export const useFrontComponentExecutionContext = ({
|
||||
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
|
||||
async (params) => {
|
||||
if (params.page === SidePanelPages.ViewRecord) {
|
||||
const { recordId, objectNameSingular, resetNavigationStack } = params;
|
||||
const { recordId, objectNameSingular, tab, resetNavigationStack } =
|
||||
params;
|
||||
|
||||
if (isMobile || !canOpenObjectInSidePanel(objectNameSingular)) {
|
||||
if (isDefined(tab)) {
|
||||
setRecordPageActiveTabId({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tabId: tab,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
await navigate(AppPath.RecordShowPage, {
|
||||
objectNameSingular,
|
||||
objectRecordId: recordId,
|
||||
@@ -143,6 +154,7 @@ export const useFrontComponentExecutionContext = ({
|
||||
openRecordInSidePanelInternal({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tab,
|
||||
resetNavigationStack,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type getDefaultStore } from 'jotai';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
|
||||
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
|
||||
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
|
||||
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const setRecordPageActiveTabId = ({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tabId,
|
||||
store,
|
||||
}: {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
tabId: string;
|
||||
store: ReturnType<typeof getDefaultStore>;
|
||||
}) => {
|
||||
// Dashboards resolve their page layout from record data, not object metadata
|
||||
if (objectNameSingular === CoreObjectNameSingular.Dashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectMetadataItem = store.get(
|
||||
objectMetadataItemFamilySelector.selectorFamily({
|
||||
objectName: objectNameSingular,
|
||||
objectNameType: 'singular',
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordPageLayout = store.get(
|
||||
recordPageLayoutByObjectMetadataIdFamilySelector.selectorFamily({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const pageLayoutId = isDefined(recordPageLayout)
|
||||
? recordPageLayout.id
|
||||
: getDefaultRecordPageLayoutId({
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
});
|
||||
|
||||
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId,
|
||||
layoutType: PageLayoutType.RECORD_PAGE,
|
||||
targetRecordIdentifier: {
|
||||
id: recordId,
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
},
|
||||
});
|
||||
|
||||
store.set(
|
||||
activeTabIdComponentState.atomFamily({ instanceId: tabListInstanceId }),
|
||||
tabId,
|
||||
);
|
||||
};
|
||||
+38
@@ -7,15 +7,19 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
|
||||
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
|
||||
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
|
||||
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
|
||||
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
|
||||
import { viewableRecordNameSingularComponentState } from '@/side-panel/pages/record-page/states/viewableRecordNameSingularComponentState';
|
||||
import { sidePanelNavigationMorphItemsByPageState } from '@/side-panel/states/sidePanelNavigationMorphItemsByPageState';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { ContextStorePageType, SidePanelPages } from 'twenty-shared/types';
|
||||
import { useIcons } from 'twenty-ui/icon';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
|
||||
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
|
||||
|
||||
@@ -206,6 +210,40 @@ describe('useOpenRecordInSidePanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should preset the record page active tab when a tab is provided', () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
const recordId = 'record-123';
|
||||
const objectNameSingular = 'person';
|
||||
|
||||
act(() => {
|
||||
result.current.openRecordInSidePanel({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tab: 'tab-emails',
|
||||
});
|
||||
});
|
||||
|
||||
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId: getDefaultRecordPageLayoutId({
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
}),
|
||||
layoutType: PageLayoutType.RECORD_PAGE,
|
||||
targetRecordIdentifier: {
|
||||
id: recordId,
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
activeTabIdComponentState.atomFamily({
|
||||
instanceId: tabListInstanceId,
|
||||
}),
|
||||
),
|
||||
).toBe('tab-emails');
|
||||
});
|
||||
|
||||
it('should not open title cell when isNewRecord is false', () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorF
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { viewableRecordIdState } from '@/object-record/record-side-panel/states/viewableRecordIdState';
|
||||
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
|
||||
import { setRecordPageActiveTabId } from '@/page-layout/utils/setRecordPageActiveTabId';
|
||||
import {
|
||||
ContextStorePageType,
|
||||
CoreObjectNameSingular,
|
||||
@@ -41,14 +42,25 @@ export const useOpenRecordInSidePanel = () => {
|
||||
({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tab,
|
||||
isNewRecord = false,
|
||||
resetNavigationStack = false,
|
||||
}: {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
tab?: string;
|
||||
isNewRecord?: boolean;
|
||||
resetNavigationStack?: boolean;
|
||||
}) => {
|
||||
if (isDefined(tab)) {
|
||||
setRecordPageActiveTabId({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
tabId: tab,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
const navigationStack = store.get(sidePanelNavigationStackState.atom);
|
||||
|
||||
const currentNavigationStackItem = navigationStack.at(-1);
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export type OpenSidePanelPageParams =
|
||||
page: SidePanelPages.ViewRecord;
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
tab?: string;
|
||||
resetNavigationStack?: boolean;
|
||||
}
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user