diff --git a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx index 80dbdddab7..914ddeedcb 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/front-components.mdx @@ -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 ( + + ); +}; + +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. diff --git a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx index 40bf93ed84..67a1d2b9d8 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx +++ b/packages/twenty-front/src/modules/front-components/hooks/__tests__/useFrontComponentExecutionContext.test.tsx @@ -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[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; diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts index 9e2cfa626c..7a67906739 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -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, }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts b/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts new file mode 100644 index 0000000000..e9de1adfa4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/setRecordPageActiveTabId.ts @@ -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; +}) => { + // 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, + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx index fa72ce6b92..6af5b33a29 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useOpenRecordInSidePanel.test.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts index c08e01d185..6806c2e0f2 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts @@ -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); diff --git a/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts index b765079939..3c9ee49c86 100644 --- a/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/sdk/front-component/globals/frontComponentHostCommunicationApi.ts @@ -18,6 +18,7 @@ export type OpenSidePanelPageParams = page: SidePanelPages.ViewRecord; recordId: string; objectNameSingular: string; + tab?: string; resetNavigationStack?: boolean; } | {