Compare commits

..
Author SHA1 Message Date
victorjzq 15a6cbe70a fix: stop event propagation on file remove in AI chat to prevent preview open
When clicking the X button on an uploaded file in the AI chat context preview,
the click event bubbled up to the StyledClickableContainer parent, triggering
handleClick (open file preview) instead of just removing the file.

Fixes #18298
2026-03-20 00:10:54 +07:00
13 changed files with 37 additions and 118 deletions
@@ -7,7 +7,7 @@ import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useCallback, useContext } from 'react';
import { type MouseEvent, useCallback, useContext } from 'react';
import { type ExtendedFileUIPart } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { AvatarOrIcon, Chip, ChipVariant } from 'twenty-ui/components';
@@ -71,11 +71,18 @@ export const AgentChatFilePreview = ({
/>
);
const rightComponent = onRemove ? (
const handleRemove = onRemove
? (e: MouseEvent) => {
e.stopPropagation();
onRemove();
}
: undefined;
const rightComponent = handleRemove ? (
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
onClick={handleRemove}
/>
) : undefined;
@@ -58,17 +58,15 @@ export const computeContextStoreFilters = ({
]);
}
if (contextStoreTargetedRecordsRule.mode === 'selection') {
if (contextStoreTargetedRecordsRule.selectedRecordIds.length === 0) {
return { id: { in: [] } };
}
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
{
id: {
in: contextStoreTargetedRecordsRule.selectedRecordIds,
},
},
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0
? {
id: {
in: contextStoreTargetedRecordsRule.selectedRecordIds,
},
}
: undefined,
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
@@ -1,7 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { renderHook } from '@testing-library/react';
@@ -16,9 +15,6 @@ jest.mock('@/object-record/hooks/useRefetchAggregateQueries', () => ({
refetchAggregateQueries: jest.fn(),
}),
}));
jest.mock('@/object-record/hooks/useRefetchFindManyRecords', () => ({
useRefetchFindManyRecords: jest.fn(),
}));
jest.mock('@/object-record/hooks/useIncrementalFetchAndMutateRecords');
const mockUseObjectMetadataItem = jest.mocked(useObjectMetadataItem);
@@ -29,7 +25,6 @@ const mockUseUpdateManyRecords = jest.mocked(useUpdateManyRecords);
const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
useIncrementalFetchAndMutateRecords,
);
const mockUseRefetchFindManyRecords = jest.mocked(useRefetchFindManyRecords);
describe('useIncrementalUpdateManyRecords', () => {
const mockUpdateManyRecords = jest.fn();
@@ -58,11 +53,6 @@ describe('useIncrementalUpdateManyRecords', () => {
updateProgress: mockUpdateProgress,
cancel: jest.fn(),
});
const mockRefetchFindManyRecords = jest.fn();
mockUseRefetchFindManyRecords.mockReturnValue({
refetchFindManyRecords: mockRefetchFindManyRecords,
});
});
it('should call incrementalFetchAndMutate and execute mutations via useUpdateManyRecords', async () => {
@@ -91,7 +81,6 @@ describe('useIncrementalUpdateManyRecords', () => {
delayInMsBetweenRequests: 50,
skipRegisterObjectOperation: true,
skipRefetchAggregateQueries: true,
skipOptimisticEffect: true,
abortSignal: expect.any(AbortSignal),
});
expect(mockUpdateProgress).toHaveBeenCalledWith(2, 2);
@@ -113,10 +102,6 @@ describe('useIncrementalUpdateManyRecords', () => {
},
},
});
const { refetchFindManyRecords } =
mockUseRefetchFindManyRecords.mock.results[0].value;
expect(refetchFindManyRecords).toHaveBeenCalled();
});
it('should pass abortSignal to updateManyRecords', async () => {
@@ -1,12 +1,11 @@
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
@@ -40,10 +39,6 @@ export const useIncrementalUpdateManyRecords = <
const { refetchAggregateQueries } = useRefetchAggregateQueries();
const { refetchFindManyRecords } = useRefetchFindManyRecords({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const {
incrementalFetchAndMutate,
progress,
@@ -73,7 +68,6 @@ export const useIncrementalUpdateManyRecords = <
delayInMsBetweenRequests: delayInMsBetweenMutations,
skipRegisterObjectOperation: true,
skipRefetchAggregateQueries: true,
skipOptimisticEffect: true,
abortSignal,
});
@@ -84,7 +78,6 @@ export const useIncrementalUpdateManyRecords = <
},
);
} finally {
await refetchFindManyRecords();
await refetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
@@ -1,24 +0,0 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { capitalize } from 'twenty-shared/utils';
export const useRefetchFindManyRecords = ({
objectMetadataNamePlural,
}: {
objectMetadataNamePlural: string;
}) => {
const apolloCoreClient = useApolloCoreClient();
const refetchFindManyRecords = async () => {
const findManyRecordsQueryName = `FindMany${capitalize(
objectMetadataNamePlural,
)}`;
await apolloCoreClient.refetchQueries({
include: [findManyRecordsQueryName],
});
};
return {
refetchFindManyRecords,
};
};
@@ -1,7 +1,6 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
@@ -38,7 +37,6 @@ export const RecordBoardClickOutsideEffect = () => {
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
RECORD_BOARD_CARD_CLICK_OUTSIDE_ID,
LINK_CHIP_CLICK_OUTSIDE_ID,
SIDE_PANEL_CLICK_OUTSIDE_ID,
],
listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID,
refs: [],
@@ -1,7 +1,6 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
@@ -31,7 +30,6 @@ export const RecordTableBodyFocusClickOutsideEffect = ({
COMMAND_MENU_CLICK_OUTSIDE_ID,
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
MODAL_BACKDROP_CLICK_OUTSIDE_ID,
SIDE_PANEL_CLICK_OUTSIDE_ID,
],
listenerId: RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
refs: [tableBodyRef],
@@ -1,22 +1,15 @@
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { UpdateMultipleRecordsFooter } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsFooter';
import { UpdateMultipleRecordsForm } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsForm';
import { useUpdateMultipleRecordsActions } from '@/object-record/record-update-multiple/hooks/useUpdateMultipleRecordsActions';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID =
'update-multiple-records-confirmation';
const StyledShowPageRightContainer = styled.div`
display: flex;
flex-direction: column;
@@ -48,30 +41,23 @@ export const UpdateMultipleRecordsContainer = ({
contextStoreInstanceId,
});
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
contextStoreInstanceId,
);
const hasSelectedRecords = contextStoreNumberOfSelectedRecords > 0;
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const [fieldUpdates, setFieldUpdates] = useState<UpdateMultipleRecordsState>(
{},
);
const handleUpdateClick = () => {
openModal(UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID);
};
const handleConfirmedUpdate = async () => {
const handleUpdate = async () => {
try {
await updateRecords(fieldUpdates);
closeSidePanelMenu();
const count = await updateRecords(fieldUpdates);
if (count !== undefined) {
enqueueSuccessSnackBar({
message: t`Successfully updated ${count} records`,
});
closeSidePanelMenu();
}
} catch (error) {
enqueueErrorSnackBar({
message:
@@ -113,20 +99,12 @@ export const UpdateMultipleRecordsContainer = ({
<UpdateMultipleRecordsFooter
isUpdating={isUpdating}
progress={progress}
onUpdate={handleUpdateClick}
onUpdate={handleUpdate}
onCancel={handleCancel}
isUpdateDisabled={!hasChanges || !hasSelectedRecords}
isUpdateDisabled={!hasChanges}
/>
</StyledShowPageRightContainer>
</ShowPageContainer>
<ConfirmationModal
modalInstanceId={UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID}
title={t`Update ${contextStoreNumberOfSelectedRecords} records`}
subtitle={t`This will modify ${contextStoreNumberOfSelectedRecords} records. This action cannot be undone.`}
onConfirmClick={handleConfirmedUpdate}
confirmButtonText={t`Update records`}
confirmButtonAccent="blue"
/>
</SidePanelProvider>
);
};
@@ -62,8 +62,13 @@ export const useUpdateMultipleRecordsActions = ({
filter: graphqlFilter,
});
const updateRecords = async (fieldsToUpdate: Record<string, any>) => {
const count = await incrementalUpdateManyRecords(fieldsToUpdate);
return count;
};
return {
updateRecords: incrementalUpdateManyRecords,
updateRecords,
isUpdating,
progress,
cancel,
@@ -1,6 +1,5 @@
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup';
import {
@@ -11,7 +10,6 @@ import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingS
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
import { ResizablePanelGap } from '@/ui/layout/resizable-panel/components/ResizablePanelGap';
import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
@@ -140,16 +138,11 @@ export const SidePanelForDesktop = () => {
isResizing={isResizing}
onTransitionEnd={handleTransitionEnd}
data-side-panel=""
data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID}
>
<StyledSidePanel>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
<ParentClickOutsideIdContext.Provider
value={SIDE_PANEL_CLICK_OUTSIDE_ID}
>
{shouldShowContent && <SidePanelRouter />}
</ParentClickOutsideIdContext.Provider>
{shouldShowContent && <SidePanelRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
@@ -1 +0,0 @@
export const SIDE_PANEL_CLICK_OUTSIDE_ID = 'side-panel';
@@ -10,7 +10,6 @@ import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContaine
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -142,7 +141,6 @@ export const DropdownInternalContainer = ({
};
const { excludedClickOutsideId } = useContext(ClickOutsideListenerContext);
const parentClickOutsideId = useContext(ParentClickOutsideIdContext);
return (
<>
@@ -164,11 +162,7 @@ export const DropdownInternalContainer = ({
isDropdownInModal={isDropdownInModal}
>
<OverlayContainer>
<StyledDropdownInsideContainer
id={dropdownId}
data-select-disable
data-click-outside-id={parentClickOutsideId}
>
<StyledDropdownInsideContainer id={dropdownId} data-select-disable>
{dropdownComponents}
</StyledDropdownInsideContainer>
</OverlayContainer>
@@ -1,5 +0,0 @@
import { createContext } from 'react';
export const ParentClickOutsideIdContext = createContext<string | undefined>(
undefined,
);