From 5588ddf829098a5530af25aad9210aef875adf6a Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 16 Jul 2026 16:13:15 +0200 Subject: [PATCH] Fix delete/destroy/restore record commands on pages without a record index (#22952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug On a standalone page (`/page/:pageLayoutId` — a custom app page or standalone page layout), opening a record in the side panel and running **Delete** from the Options menu fails with an error toast: > Record index ID and object metadata are required to delete records The record is not deleted. The same guard breaks **Destroy** and **Restore**. ## Root cause `buildHeadlessCommandContextApi` only derives `recordIndexId` when the context store holds a `currentViewId`. On standalone pages there is no view, and `useOpenRecordInSidePanel` copies that null view id into the side panel context, so the delete/destroy/restore commands throw at mount — before executing anything. The throw is caught by `CommandMenuItemErrorBoundary` and surfaces as the toast (also reported to Sentry). The commands only use `recordIndexId` to reset table row selection and remove records from the record board — cleanup that is meaningless when no record index is on screen. The mutation itself only needs `objectMetadataItem` and the graphql filter, which are both available. ## Fix - Keep throwing when `objectMetadataItem` is missing (genuinely required). - Make `recordIndexId` optional: pass the existing `PLACEHOLDER_RECORD_INDEX_ID` to the selection hooks (they must be called unconditionally) and skip the selection cleanup at execute time when there is no record index — same pattern `useResetRecordIndexSelection` already uses. The constant is extracted to a shared file. ## Verified - **Bug path**: on a standalone page, opened a record in the side panel via search, ran Delete Task from the Options menu → record soft-deleted (checked `deletedAt` in DB), side panel closed, no error toast, no console error. - **Regression**: on the tasks index table, selected a row and ran Delete Task from the command menu → record deleted, row removed, table selection reset, no errors. Review in cubic --- .../components/DeleteRecordsCommand.tsx | 21 ++++++++++------- .../components/DestroyRecordsCommand.tsx | 21 ++++++++++------- .../components/RestoreRecordsCommand.tsx | 23 ++++++++++++------- .../constants/PlaceholderRecordIndexId.ts | 1 + .../hooks/useResetRecordIndexSelection.ts | 3 +-- 5 files changed, 43 insertions(+), 26 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-index/constants/PlaceholderRecordIndexId.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DeleteRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DeleteRecordsCommand.tsx index 9b1b2e83dc..7ac438e6bf 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DeleteRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DeleteRecordsCommand.tsx @@ -5,6 +5,7 @@ import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { useIncrementalDeleteManyRecords } from '@/object-record/hooks/useIncrementalDeleteManyRecords'; import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { PLACEHOLDER_RECORD_INDEX_ID } from '@/object-record/record-index/constants/PlaceholderRecordIndexId'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { type RecordGqlOperationFilter } from 'twenty-shared/types'; @@ -14,18 +15,20 @@ export const DeleteRecordsCommand = () => { const { recordIndexId, objectMetadataItem, selectedRecords, graphqlFilter } = useHeadlessCommandContextApi(); - if (!isDefined(recordIndexId) || !isDefined(objectMetadataItem)) { - throw new Error( - 'Record index ID and object metadata are required to delete records', - ); + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata is required to delete records'); } const recordId = selectedRecords[0]?.id; - const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { resetTableRowSelection } = useResetTableRowSelection( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const { removeSelectedRecordsFromRecordBoard } = - useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + useRemoveSelectedRecordsFromRecordBoard( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const noMatchFilter: RecordGqlOperationFilter = { id: { in: [] } }; @@ -45,8 +48,10 @@ export const DeleteRecordsCommand = () => { const { closeSidePanelMenu } = useSidePanelMenu(); const handleExecute = async () => { - removeSelectedRecordsFromRecordBoard(); - resetTableRowSelection(); + if (isDefined(recordIndexId)) { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); + } closeSidePanelMenu(); if (isDefined(recordId)) { diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DestroyRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DestroyRecordsCommand.tsx index 5a3c8517a4..a6b674b191 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DestroyRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/DestroyRecordsCommand.tsx @@ -3,6 +3,7 @@ import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords'; import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { PLACEHOLDER_RECORD_INDEX_ID } from '@/object-record/record-index/constants/PlaceholderRecordIndexId'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { t } from '@lingui/core/macro'; @@ -19,10 +20,8 @@ export const DestroyRecordsCommand = () => { isInSidePanel, } = useHeadlessCommandContextApi(); - if (!isDefined(recordIndexId) || !isDefined(objectMetadataItem)) { - throw new Error( - 'Record index ID and object metadata are required to destroy records', - ); + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata is required to destroy records'); } const isSingleRecord = selectedRecords.length === 1; @@ -30,9 +29,13 @@ export const DestroyRecordsCommand = () => { const navigateApp = useNavigateApp(); const { closeSidePanelMenu } = useSidePanelMenu(); - const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { resetTableRowSelection } = useResetTableRowSelection( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const { removeSelectedRecordsFromRecordBoard } = - useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + useRemoveSelectedRecordsFromRecordBoard( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const noMatchFilter: RecordGqlOperationFilter = { id: { in: [] } }; @@ -53,8 +56,10 @@ export const DestroyRecordsCommand = () => { }); const handleExecute = async () => { - removeSelectedRecordsFromRecordBoard(); - resetTableRowSelection(); + if (isDefined(recordIndexId)) { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); + } if (!isDefined(graphqlFilter)) { throw new Error('Cannot destroy records without a valid filter'); diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/RestoreRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/RestoreRecordsCommand.tsx index 4734557b04..a216d01b5c 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/RestoreRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/RestoreRecordsCommand.tsx @@ -4,6 +4,7 @@ import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryP import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; +import { PLACEHOLDER_RECORD_INDEX_ID } from '@/object-record/record-index/constants/PlaceholderRecordIndexId'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { t } from '@lingui/core/macro'; @@ -14,17 +15,19 @@ export const RestoreRecordsCommand = () => { const { recordIndexId, objectMetadataItem, selectedRecords, graphqlFilter } = useHeadlessCommandContextApi(); - if (!isDefined(recordIndexId) || !isDefined(objectMetadataItem)) { - throw new Error( - 'Record index ID and object metadata are required to restore records', - ); + if (!isDefined(objectMetadataItem)) { + throw new Error('Object metadata is required to restore records'); } const isSingleRecord = selectedRecords.length === 1; - const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { resetTableRowSelection } = useResetTableRowSelection( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const { removeSelectedRecordsFromRecordBoard } = - useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + useRemoveSelectedRecordsFromRecordBoard( + recordIndexId ?? PLACEHOLDER_RECORD_INDEX_ID, + ); const { closeSidePanelMenu } = useSidePanelMenu(); const { restoreManyRecords } = useRestoreManyRecords({ @@ -50,7 +53,9 @@ export const RestoreRecordsCommand = () => { }); const handleExecute = async () => { - removeSelectedRecordsFromRecordBoard(); + if (isDefined(recordIndexId)) { + removeSelectedRecordsFromRecordBoard(); + } closeSidePanelMenu(); if (!isDefined(graphqlFilter)) { @@ -60,7 +65,9 @@ export const RestoreRecordsCommand = () => { const recordsToRestore = await fetchAllRecordIds(); const recordIdsToRestore = recordsToRestore.map((record) => record.id); - resetTableRowSelection(); + if (isDefined(recordIndexId)) { + resetTableRowSelection(); + } await restoreManyRecords({ idsToRestore: recordIdsToRestore, diff --git a/packages/twenty-front/src/modules/object-record/record-index/constants/PlaceholderRecordIndexId.ts b/packages/twenty-front/src/modules/object-record/record-index/constants/PlaceholderRecordIndexId.ts new file mode 100644 index 0000000000..dc97635b63 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/constants/PlaceholderRecordIndexId.ts @@ -0,0 +1 @@ +export const PLACEHOLDER_RECORD_INDEX_ID = 'placeholder-record-index-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetRecordIndexSelection.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetRecordIndexSelection.ts index 136916dc85..a69d694e92 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetRecordIndexSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useResetRecordIndexSelection.ts @@ -3,6 +3,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/ import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; +import { PLACEHOLDER_RECORD_INDEX_ID } from '@/object-record/record-index/constants/PlaceholderRecordIndexId'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; @@ -11,8 +12,6 @@ import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; -const PLACEHOLDER_RECORD_INDEX_ID = 'placeholder-record-index-id'; - export const useResetRecordIndexSelection = ( contextStoreInstanceId?: string, ) => {