diff --git a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx index 4dd93b41f5..e34358621d 100644 --- a/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx +++ b/packages/twenty-apps/examples/postcard/src/components/card.front-component.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; -import { useRecordId } from 'twenty-sdk/front-component'; +import { useSelectedRecordIds } from 'twenty-sdk/front-component'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { isDefined } from 'twenty-shared/utils'; @@ -72,7 +72,8 @@ const CardDisplay = ({ }; const PostCardPreview = () => { - const recordId = useRecordId(); + const selectedRecordIds = useSelectedRecordIds(); + const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null; const [postCard, setPostCard] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/packages/twenty-apps/examples/postcard/src/components/generate-post-card-component-effect.tsx b/packages/twenty-apps/examples/postcard/src/components/generate-post-card-component-effect.tsx index 5b2fb35044..33a0135887 100644 --- a/packages/twenty-apps/examples/postcard/src/components/generate-post-card-component-effect.tsx +++ b/packages/twenty-apps/examples/postcard/src/components/generate-post-card-component-effect.tsx @@ -1,8 +1,7 @@ import { useEffect } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; -import { useRecordId, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component'; +import { useSelectedRecordIds, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; const SYSTEM_PROMPT = @@ -11,12 +10,13 @@ const SYSTEM_PROMPT = 'text, nothing else — no greeting label, no sign-off label, just the message.'; const GeneratePostCardEffect = () => { - const recordId = useRecordId(); + const selectedRecordIds = useSelectedRecordIds(); + const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null; useEffect(() => { - if (!isDefined(recordId)) { + if (recordId === null) { enqueueSnackbar({ - message: 'No record selected', + message: 'Please select exactly one record', variant: 'error', }); unmountFrontComponent(); diff --git a/packages/twenty-apps/examples/postcard/src/components/send-post-cards-component-effect.tsx b/packages/twenty-apps/examples/postcard/src/components/send-post-cards-component-effect.tsx index 2493896005..e72dcd523c 100644 --- a/packages/twenty-apps/examples/postcard/src/components/send-post-cards-component-effect.tsx +++ b/packages/twenty-apps/examples/postcard/src/components/send-post-cards-component-effect.tsx @@ -1,62 +1,44 @@ import { useEffect } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; -import { useRecordId, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component'; +import { useSelectedRecordIds, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component'; import { CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'twenty-shared/utils'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; const SendPostCardsEffect = () => { - const recordId = useRecordId(); + const selectedRecordIds = useSelectedRecordIds(); useEffect(() => { const send = async () => { try { - await updateProgress(0.1); - const client = new CoreApiClient(); - - let idsToSend: string[] = []; - - if (isDefined(recordId)) { - idsToSend = [recordId]; - } else { - const { postCards } = await client.query({ - postCards: { - __args: { - filter: { status: { eq: 'DRAFT' } }, - }, - edges: { node: { id: true } }, - }, + if (selectedRecordIds.length === 0) { + await enqueueSnackbar({ + message: 'No postcards selected', + variant: 'error', }); - - idsToSend = - postCards?.edges?.map( - (edge: { node: { id: string; status: true } }) => edge.node.id, - ) ?? []; - } - - if (idsToSend.length === 0) { - await updateProgress(1); await unmountFrontComponent(); return; } + await updateProgress(0.1); + const client = new CoreApiClient(); + await updateProgress(0.3); - for (let i = 0; i < idsToSend.length; i++) { + for (let i = 0; i < selectedRecordIds.length; i++) { await client.mutation({ updatePostCard: { __args: { - id: idsToSend[i], + id: selectedRecordIds[i], data: { status: 'SENT' }, }, id: true, }, }); - await updateProgress(0.3 + (0.7 * (i + 1)) / idsToSend.length); + await updateProgress(0.3 + (0.7 * (i + 1)) / selectedRecordIds.length); } - const count = idsToSend.length; + const count = selectedRecordIds.length; await enqueueSnackbar({ message: `${count} postcard${count > 1 ? 's' : ''} sent`, @@ -74,7 +56,7 @@ const SendPostCardsEffect = () => { }; send(); - }, [recordId]); + }, [selectedRecordIds]); return null; }; diff --git a/packages/twenty-docs/developers/extend/apps/front-components.mdx b/packages/twenty-docs/developers/extend/apps/front-components.mdx index 8b0f456a06..ebd5adde27 100644 --- a/packages/twenty-docs/developers/extend/apps/front-components.mdx +++ b/packages/twenty-docs/developers/extend/apps/front-components.mdx @@ -223,7 +223,8 @@ Available hooks: | Hook | Returns | Description | |------|---------|-------------| | `useUserId()` | `string` or `null` | The current user's ID | -| `useRecordId()` | `string` or `null` | The current record's ID (when placed on a record page) | +| `useSelectedRecordIds()` | `string[]` | All selected record IDs (empty array if none selected) | +| `useRecordId()` | `string` or `null` | **Deprecated.** Use `useSelectedRecordIds()` instead | | `useFrontComponentId()` | `string` | This component instance's ID | | `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function | @@ -286,6 +287,61 @@ export default defineFrontComponent({ }); ``` +### Working with multiple records + +Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations: + +```tsx src/front-components/bulk-export.tsx +import { defineFrontComponent } from 'twenty-sdk/define'; +import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component'; +import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component'; +import { CoreApiClient } from 'twenty-sdk/clients'; + +const BulkExport = () => { + const selectedRecordIds = useSelectedRecordIds(); + + const handleExport = async () => { + const client = new CoreApiClient(); + + for (const recordId of selectedRecordIds) { + await client.mutation({ + updateTask: { + __args: { id: recordId, data: { exported: true } }, + id: true, + }, + }); + } + + await enqueueSnackbar({ + message: `Exported ${selectedRecordIds.length} records`, + variant: 'success', + }); + + await closeSidePanel(); + }; + + return ( +
+

Export {selectedRecordIds.length} selected record(s)?

+ +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901', + name: 'bulk-export', + description: 'Export selected records', + component: BulkExport, + command: { + universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902', + label: 'Bulk Export', + availabilityType: 'RECORD_SELECTION', + conditionalAvailabilityExpression: numberOfSelectedRecords > 0, + }, +}); +``` + ## Command options Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page. diff --git a/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx index 72bca656d1..4a508c2e96 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/EventForwarding.stories.tsx @@ -31,6 +31,7 @@ const meta: Meta = { frontComponentId: 'storybook-test', userId: null, recordId: null, + selectedRecordIds: [], }, colorScheme: 'light', frontComponentHostCommunicationApi: createHostApiMocks(), diff --git a/packages/twenty-front-component-renderer/src/__stories__/FrontComponentRenderer.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/FrontComponentRenderer.stories.tsx index 7f1d3796f5..985bf71444 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/FrontComponentRenderer.stories.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/FrontComponentRenderer.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { frontComponentId: 'storybook-test', userId: null, recordId: null, + selectedRecordIds: [], }, }, beforeEach: () => { @@ -122,6 +123,7 @@ export const SdkContext: Story = { frontComponentId: 'sdk-context-test', userId: 'test-user-abc-123', recordId: null, + selectedRecordIds: [], }, }, play: async ({ canvasElement }) => { diff --git a/packages/twenty-front-component-renderer/src/__stories__/UILibraries.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/UILibraries.stories.tsx index e850e66e52..3c02969617 100644 --- a/packages/twenty-front-component-renderer/src/__stories__/UILibraries.stories.tsx +++ b/packages/twenty-front-component-renderer/src/__stories__/UILibraries.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { frontComponentId: 'storybook-test', userId: null, recordId: null, + selectedRecordIds: [], }, }, beforeEach: () => { diff --git a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts index 14c76ccf41..dea0e745db 100644 --- a/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-front-component-renderer/src/types/FrontComponentExecutionContext.ts @@ -1,5 +1,10 @@ export type FrontComponentExecutionContext = { frontComponentId: string; userId: string | null; + /** + * @deprecated Use `selectedRecordIds` instead. Derive single record as `selectedRecordIds.length === 1 ? selectedRecordIds[0] : null`. + */ recordId: string | null; + /** All selected record IDs */ + selectedRecordIds: string[]; }; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand.tsx index ef345d3f91..aa5a00fe5e 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand.tsx @@ -24,17 +24,14 @@ export const HeadlessFrontComponentRendererEngineCommand = () => { ); } - const recordId = - context.selectedRecords.length === 1 - ? context.selectedRecords[0].id - : undefined; + const selectedRecordIds = context.selectedRecords.map((record) => record.id); return ( ); diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx index 16408d2103..30698f5df0 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRenderer.tsx @@ -18,13 +18,13 @@ import { FindOneFrontComponentDocument } from '~/generated-metadata/graphql'; type FrontComponentRendererProps = { frontComponentId: string; commandMenuItemId?: string; - recordId?: string; + selectedRecordIds?: string[]; }; export const FrontComponentRenderer = ({ frontComponentId, commandMenuItemId, - recordId, + selectedRecordIds, }: FrontComponentRendererProps) => { const { colorScheme } = useContext(ThemeContext); const { enqueueErrorSnackBar } = useSnackBar(); @@ -38,7 +38,7 @@ export const FrontComponentRenderer = ({ useFrontComponentExecutionContext({ frontComponentId, commandMenuItemId, - recordId, + selectedRecordIds, }); const handleError = useCallback( 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 ce24830c6e..e7728644c3 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 @@ -93,11 +93,11 @@ describe('useFrontComponentExecutionContext', () => { }); describe('executionContext', () => { - it('should return frontComponentId, userId, and recordId', () => { + it('should return frontComponentId, userId, recordId, and selectedRecordIds with single record', () => { const { result } = renderHook(() => useFrontComponentExecutionContext({ frontComponentId: FRONT_COMPONENT_ID, - recordId: 'record-456', + selectedRecordIds: ['record-456'], }), ); @@ -105,6 +105,23 @@ describe('useFrontComponentExecutionContext', () => { frontComponentId: FRONT_COMPONENT_ID, userId: 'user-123', recordId: 'record-456', + selectedRecordIds: ['record-456'], + }); + }); + + it('should return null recordId when multiple selectedRecordIds provided', () => { + const { result } = renderHook(() => + useFrontComponentExecutionContext({ + frontComponentId: FRONT_COMPONENT_ID, + selectedRecordIds: ['record-1', 'record-2', 'record-3'], + }), + ); + + expect(result.current.executionContext).toEqual({ + frontComponentId: FRONT_COMPONENT_ID, + userId: 'user-123', + recordId: null, + selectedRecordIds: ['record-1', 'record-2', 'record-3'], }); }); @@ -120,7 +137,7 @@ describe('useFrontComponentExecutionContext', () => { expect(result.current.executionContext.userId).toBeNull(); }); - it('should return null recordId when no recordId provided', () => { + it('should return null recordId and empty selectedRecordIds when no selectedRecordIds provided', () => { const { result } = renderHook(() => useFrontComponentExecutionContext({ frontComponentId: FRONT_COMPONENT_ID, @@ -128,6 +145,19 @@ describe('useFrontComponentExecutionContext', () => { ); expect(result.current.executionContext.recordId).toBeNull(); + expect(result.current.executionContext.selectedRecordIds).toEqual([]); + }); + + it('should return null recordId and empty selectedRecordIds when empty array provided', () => { + const { result } = renderHook(() => + useFrontComponentExecutionContext({ + frontComponentId: FRONT_COMPONENT_ID, + selectedRecordIds: [], + }), + ); + + expect(result.current.executionContext.recordId).toBeNull(); + expect(result.current.executionContext.selectedRecordIds).toEqual([]); }); }); 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 0848596818..fdcef1e49f 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -23,11 +23,11 @@ import { useNavigateApp } from '~/hooks/useNavigateApp'; export const useFrontComponentExecutionContext = ({ frontComponentId, commandMenuItemId, - recordId, + selectedRecordIds, }: { frontComponentId: string; commandMenuItemId?: string; - recordId?: string; + selectedRecordIds?: string[]; }): { executionContext: FrontComponentExecutionContext; frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi; @@ -127,7 +127,8 @@ export const useFrontComponentExecutionContext = ({ const executionContext: FrontComponentExecutionContext = { frontComponentId, userId: currentUser?.id ?? null, - recordId: recordId ?? null, + recordId: selectedRecordIds?.length === 1 ? selectedRecordIds[0] : null, + selectedRecordIds: selectedRecordIds ?? [], }; const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] = diff --git a/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx index bb386df8c0..5841d8f3a4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx @@ -42,13 +42,16 @@ export const FrontComponentWidgetRenderer = ({ } const frontComponentId = configuration.frontComponentId; + const selectedRecordIds = isDefined(targetRecordIdentifier?.id) + ? [targetRecordIdentifier.id] + : undefined; return ( diff --git a/packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx index 44a7a56a1e..142ba5934b 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/front-component/components/SidePanelFrontComponentPage.tsx @@ -24,11 +24,17 @@ export const SidePanelFrontComponentPage = () => { return null; } + const selectedRecordIds = isDefined( + viewableFrontComponentRecordContext?.recordId, + ) + ? [viewableFrontComponentRecordContext.recordId] + : undefined; + return ( ); diff --git a/packages/twenty-sdk/src/sdk/front-component/hooks/useRecordId.ts b/packages/twenty-sdk/src/sdk/front-component/hooks/useRecordId.ts index ea9562b00a..926ecd94df 100644 --- a/packages/twenty-sdk/src/sdk/front-component/hooks/useRecordId.ts +++ b/packages/twenty-sdk/src/sdk/front-component/hooks/useRecordId.ts @@ -3,8 +3,13 @@ import { useFrontComponentExecutionContext } from './useFrontComponentExecutionC const selectRecordId = ( context: FrontComponentExecutionContext, -): string | null => context.recordId; +): string | null => + context.selectedRecordIds.length === 1 ? context.selectedRecordIds[0] : null; +/** + * @deprecated Use `useSelectedRecordIds()` instead. For single-record operations, + * use `selectedRecordIds.length === 1 ? selectedRecordIds[0] : null`. + */ export const useRecordId = (): string | null => { return useFrontComponentExecutionContext(selectRecordId); }; diff --git a/packages/twenty-sdk/src/sdk/front-component/hooks/useSelectedRecordIds.ts b/packages/twenty-sdk/src/sdk/front-component/hooks/useSelectedRecordIds.ts new file mode 100644 index 0000000000..fc438e7538 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component/hooks/useSelectedRecordIds.ts @@ -0,0 +1,10 @@ +import { type FrontComponentExecutionContext } from '../types/FrontComponentExecutionContext'; +import { useFrontComponentExecutionContext } from './useFrontComponentExecutionContext'; + +const selectSelectedRecordIds = ( + context: FrontComponentExecutionContext, +): string[] => context.selectedRecordIds; + +export const useSelectedRecordIds = (): string[] => { + return useFrontComponentExecutionContext(selectSelectedRecordIds); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component/index.ts b/packages/twenty-sdk/src/sdk/front-component/index.ts index 7df4f38355..eb7b31d03e 100644 --- a/packages/twenty-sdk/src/sdk/front-component/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component/index.ts @@ -38,6 +38,7 @@ export { updateProgress } from './functions/updateProgress'; export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext'; export { useFrontComponentId } from './hooks/useFrontComponentId'; export { useRecordId } from './hooks/useRecordId'; +export { useSelectedRecordIds } from './hooks/useSelectedRecordIds'; export { useUserId } from './hooks/useUserId'; export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext'; export { getFrontComponentCommandErrorDedupeKey } from './utils/getFrontComponentCommandErrorDedupeKey'; diff --git a/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts b/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts index 14c76ccf41..dea0e745db 100644 --- a/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts +++ b/packages/twenty-sdk/src/sdk/front-component/types/FrontComponentExecutionContext.ts @@ -1,5 +1,10 @@ export type FrontComponentExecutionContext = { frontComponentId: string; userId: string | null; + /** + * @deprecated Use `selectedRecordIds` instead. Derive single record as `selectedRecordIds.length === 1 ? selectedRecordIds[0] : null`. + */ recordId: string | null; + /** All selected record IDs */ + selectedRecordIds: string[]; };