diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountCommand.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountCommand.ts index 08ff584e874e2..114e89667d676 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountCommand.ts +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountCommand.ts @@ -20,6 +20,7 @@ type MountCommandParams = { availabilityType?: CommandMenuItemAvailabilityType; availabilityObjectMetadataId?: string | null; payload?: CommandMenuItemPayload | null; + isInSidePanel?: boolean; }; export const useMountCommand = () => { @@ -39,12 +40,14 @@ export const useMountCommand = () => { availabilityType, availabilityObjectMetadataId, payload, + isInSidePanel, }: MountCommandParams) => { const headlessEngineCommandContextApi = buildHeadlessCommandContextApi({ store, contextStoreInstanceId, engineComponentKey, payload, + isInSidePanel, }); const commandState = isDefined(frontComponentId) 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 1fde0f7f98dfd..9fe9da93ec90d 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 @@ -4,14 +4,20 @@ import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryP import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords'; import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { t } from '@lingui/core/macro'; import { AppPath, type RecordGqlOperationFilter } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { useNavigateApp } from '~/hooks/useNavigateApp'; export const DestroyRecordsCommand = () => { - const { recordIndexId, objectMetadataItem, selectedRecords, graphqlFilter } = - useHeadlessCommandContextApi(); + const { + recordIndexId, + objectMetadataItem, + selectedRecords, + graphqlFilter, + isInSidePanel, + } = useHeadlessCommandContextApi(); if (!isDefined(recordIndexId) || !isDefined(objectMetadataItem)) { throw new Error( @@ -22,6 +28,7 @@ export const DestroyRecordsCommand = () => { const isSingleRecord = selectedRecords.length === 1; const navigateApp = useNavigateApp(); + const { closeSidePanelMenu } = useSidePanelMenu(); const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); const { removeSelectedRecordsFromRecordBoard } = @@ -56,9 +63,13 @@ export const DestroyRecordsCommand = () => { await incrementalDestroyManyRecords(); if (isSingleRecord) { - navigateApp(AppPath.RecordIndexPage, { - objectNamePlural: objectMetadataItem.namePlural, - }); + if (isInSidePanel) { + closeSidePanelMenu(); + } else { + navigateApp(AppPath.RecordIndexPage, { + objectNamePlural: objectMetadataItem.namePlural, + }); + } } }; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/types/HeadlessCommandContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/types/HeadlessCommandContextApi.ts index 68a383f301d90..e92e510a41f0e 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/types/HeadlessCommandContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/types/HeadlessCommandContextApi.ts @@ -22,6 +22,7 @@ export type HeadlessEngineCommandContextApi = { selectedRecords: ObjectRecord[]; graphqlFilter: Nullable; payload: Nullable; + isInSidePanel?: boolean; }; export type HeadlessFrontComponentCommandContextApi = diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/utils/buildHeadlessCommandContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/utils/buildHeadlessCommandContextApi.ts index fa57b59581c35..6d64a45f49a90 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/utils/buildHeadlessCommandContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/utils/buildHeadlessCommandContextApi.ts @@ -25,11 +25,13 @@ export const buildHeadlessCommandContextApi = ({ contextStoreInstanceId, engineComponentKey, payload, + isInSidePanel, }: { store: Store; contextStoreInstanceId: string; engineComponentKey: EngineComponentKey; payload?: CommandMenuItemPayload | null; + isInSidePanel?: boolean; }): HeadlessEngineCommandContextApi => { const objectMetadataItemId = store.get( contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({ @@ -127,5 +129,6 @@ export const buildHeadlessCommandContextApi = ({ selectedRecords, graphqlFilter, payload: payload ?? null, + isInSidePanel: isInSidePanel ?? false, }; }; diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemClick.ts b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemClick.ts index 7c30fa2b55a1f..ddf89920274bb 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemClick.ts +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemClick.ts @@ -81,6 +81,7 @@ export const useCommandMenuItemClick = ({ availabilityType: item.availabilityType, availabilityObjectMetadataId: item.availabilityObjectMetadataId, payload: item.payload ?? undefined, + isInSidePanel: commandMenuContextApi.isInSidePanel, }); return; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts index 759ab7f46851e..095f5243b0115 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts @@ -147,12 +147,17 @@ export const useDestroyManyRecords = ({ objectMetadataNamePlural: objectMetadataItem.namePlural, }); - removeNavigationMenuItemsByTargetRecordIds(recordIdsToDestroy); + const actualDestroyedRecordIds = destroyedRecords.map( + (record) => record.id, + ); + + removeNavigationMenuItemsByTargetRecordIds(actualDestroyedRecordIds); dispatchObjectRecordOperationBrowserEvent({ objectMetadataItem, operation: { type: 'destroy-many', + destroyedRecordIds: actualDestroyedRecordIds, }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts index 3d3a4bc311ba9..1d59ee518d1eb 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -9,7 +10,6 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField'; import { capitalize, isDefined } from 'twenty-shared/utils'; @@ -94,6 +94,7 @@ export const useDestroyOneRecord = ({ objectMetadataItem, operation: { type: 'destroy-one', + destroyedRecordId: idToDestroy, }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts index 6654c2160532b..dc5587632a301 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDestroyManyRecords.ts @@ -1,5 +1,6 @@ import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -12,7 +13,6 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { sleep } from '~/utils/sleep'; @@ -78,6 +78,8 @@ export const useIncrementalDestroyManyRecords = ({ recordIdsToDestroy.length / mutationPageSize, ); + const actualDestroyedRecordIds: string[] = []; + for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) { const batchedIdsToDestroy = recordIdsToDestroy.slice( batchIndex * mutationPageSize, @@ -88,7 +90,7 @@ export const useIncrementalDestroyManyRecords = ({ .map((recordId) => getRecordFromCache(recordId, apolloCoreClient.cache)) .filter(isDefined); - await apolloCoreClient + const response = await apolloCoreClient .mutate>({ mutation: destroyManyRecordsMutation, variables: { @@ -141,10 +143,18 @@ export const useIncrementalDestroyManyRecords = ({ throw error; }); + const destroyedRecordsForThisBatch = + response.data?.[mutationResponseField] ?? []; + actualDestroyedRecordIds.push( + ...destroyedRecordsForThisBatch.map((r) => r.id), + ); + if (delayInMsBetweenMutations > 0) { await sleep(delayInMsBetweenMutations); } } + + return actualDestroyedRecordIds; }; const incrementalDestroyManyRecords = async () => { @@ -152,9 +162,20 @@ export const useIncrementalDestroyManyRecords = ({ await incrementalFetchAndMutate( async ({ recordIds, totalCount, abortSignal }) => { - await destroyManyRecordsBatch(recordIds, abortSignal); - - totalDestroyedCount += recordIds.length; + const actualDestroyedRecordIds = await destroyManyRecordsBatch( + recordIds, + abortSignal, + ); + + totalDestroyedCount += actualDestroyedRecordIds.length; + + dispatchObjectRecordOperationBrowserEvent({ + objectMetadataItem, + operation: { + type: 'destroy-many', + destroyedRecordIds: actualDestroyedRecordIds, + }, + }); updateProgress(totalDestroyedCount, totalCount); }, @@ -164,13 +185,6 @@ export const useIncrementalDestroyManyRecords = ({ objectMetadataNamePlural: objectMetadataItem.namePlural, }); - dispatchObjectRecordOperationBrowserEvent({ - objectMetadataItem, - operation: { - type: 'destroy-many', - }, - }); - return totalDestroyedCount; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 8d90f72d7618a..0886ade5eb08d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -1,10 +1,14 @@ -import { useContext } from 'react'; +import { atom, useAtom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { useContext, useEffect, useMemo, useState } from 'react'; + +import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent'; +import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { type NoteTarget } from '@/activities/types/NoteTarget'; import { type TaskTarget } from '@/activities/types/TaskTarget'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; import { RecordChip } from '@/object-record/components/RecordChip'; import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; @@ -13,6 +17,7 @@ import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction'; import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { styled } from '@linaria/react'; @@ -30,11 +35,15 @@ const StyledContainer = styled.div` width: 100%; `; +export const locallyDeletedRecordIdsAtomFamily = atomFamily((_key: string) => + atom([]), +); + export const RelationFromManyFieldDisplay = () => { const { fieldValue, fieldDefinition, generateRecordChipData } = useRelationFromManyFieldDisplay(); const { isFocused } = useFieldFocus(); - const { disableChipClick, triggerEvent } = useContext(FieldContext); + const { disableChipClick, triggerEvent, recordId } = useContext(FieldContext); const { objectMetadataItems } = useObjectMetadataItems(); const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata; @@ -57,16 +66,105 @@ export const RelationFromManyFieldDisplay = () => { objectMetadataItems, }); + const [displayedFieldValue, setDisplayedFieldValue] = useState(fieldValue); + const cellKey = `${recordId}-${fieldName}`; + const [locallyDeletedIds, setLocallyDeletedIds] = useAtom( + locallyDeletedRecordIdsAtomFamily(cellKey), + ); + + useEffect(() => { + if (!fieldValue || !isArray(fieldValue)) { + setDisplayedFieldValue(fieldValue); + return; + } + + if (locallyDeletedIds.length === 0) { + setDisplayedFieldValue(fieldValue); + return; + } + + const filteredValues = fieldValue.filter((record) => { + if (!isDefined(record)) return true; + + if (locallyDeletedIds.includes(record.id)) return false; + + const hasMatchingForeignKey = Object.entries(record).some( + ([key, val]) => + key.endsWith('Id') && + typeof val === 'string' && + locallyDeletedIds.includes(val), + ); + if (hasMatchingForeignKey) return false; + + const hasDestroyedNestedRecord = Object.values(record).some( + (val) => + isDefined(val) && + typeof val === 'object' && + 'id' in val && + typeof val.id === 'string' && + locallyDeletedIds.includes(val.id), + ); + if (hasDestroyedNestedRecord) return false; + + return true; + }); + setDisplayedFieldValue(filteredValues); + }, [fieldValue, locallyDeletedIds]); + + const isRelationFromManyActivities = + (fieldName === 'noteTargets' && + objectMetadataNameSingular !== CoreObjectNameSingular.Note) || + (fieldName === 'taskTargets' && + objectMetadataNameSingular !== CoreObjectNameSingular.Task); + + const listenObjectMetadataId = useMemo(() => { + if (isRelationFromManyActivities) { + const targetName = + fieldName === 'noteTargets' + ? CoreObjectNameSingular.Note + : CoreObjectNameSingular.Task; + return objectMetadataItems.find( + (item) => item.nameSingular === targetName, + )?.id; + } + + return fieldDefinition.metadata.relationObjectMetadataId; + }, [ + isRelationFromManyActivities, + fieldName, + objectMetadataItems, + fieldDefinition.metadata.relationObjectMetadataId, + ]); + + useListenToObjectRecordOperationBrowserEvent({ + onObjectRecordOperationBrowserEvent: ( + detail: ObjectRecordOperationBrowserEventDetail, + ) => { + if (detail.operation.type === 'destroy-many') { + const destroyedIds = detail.operation.destroyedRecordIds; + setLocallyDeletedIds((prevIds) => + Array.from(new Set([...prevIds, ...destroyedIds])), + ); + } else if (detail.operation.type === 'destroy-one') { + const destroyedId = detail.operation.destroyedRecordId; + setLocallyDeletedIds((prevIds) => + Array.from(new Set([...prevIds, destroyedId])), + ); + } + }, + objectMetadataItemId: listenObjectMetadataId, + }); + const { activityTargetObjectRecords } = useActivityTargetObjectRecords( '', - fieldValue as NoteTarget[] | TaskTarget[], + displayedFieldValue as NoteTarget[] | TaskTarget[], ); - if (!isDefined(fieldValue)) { + if (!isDefined(displayedFieldValue)) { return null; } - if (!isArray(fieldValue)) { + if (!isArray(displayedFieldValue)) { return null; } @@ -79,12 +177,6 @@ export const RelationFromManyFieldDisplay = () => { objectMetadataNameSingular ?? '', ); - const isRelationFromManyActivities = - (fieldName === 'noteTargets' && - objectMetadataNameSingular !== CoreObjectNameSingular.Note) || - (fieldName === 'taskTargets' && - objectMetadataNameSingular !== CoreObjectNameSingular.Task); - if (isRelationFromManyActivities) { const objectNameSingular = fieldName === 'noteTargets' @@ -92,7 +184,7 @@ export const RelationFromManyFieldDisplay = () => { : CoreObjectNameSingular.Task; const relationFieldName = fieldName === 'noteTargets' ? 'note' : 'task'; - const chips = fieldValue + const chips = displayedFieldValue .map((record) => { if (!isDefined(record) || !isDefined(record[relationFieldName])) { return undefined; @@ -127,7 +219,7 @@ export const RelationFromManyFieldDisplay = () => { } const extractedRecords = extractTargetRecordsFromJunction({ - junctionRecords: fieldValue, + junctionRecords: displayedFieldValue, targetFields, objectMetadataItems, includeRecord: true, @@ -145,7 +237,10 @@ export const RelationFromManyFieldDisplay = () => { }) .filter(isDefined); - if (fieldValue.some(isDefined) && targetRecordsWithMetadata.length === 0) { + if ( + displayedFieldValue.some(isDefined) && + targetRecordsWithMetadata.length === 0 + ) { return null; } @@ -181,7 +276,7 @@ export const RelationFromManyFieldDisplay = () => { return ( - {fieldValue.filter(isDefined).map((record) => { + {displayedFieldValue.filter(isDefined).map((record) => { const recordChipData = generateRecordChipData(record); return ( event.properties.before.id, + ), }, }); }