From b2d36df6f9421fd7b5357c901c79436aa7219df6 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 11 Jun 2026 11:12:33 -0300 Subject: [PATCH 1/3] feat(drive-integration): adding remove button redesign [INTEG-4135] --- .../mapping/EditMappingButton.styles.ts | 60 +++++++++++ .../review/mapping/EditMappingButton.tsx | 60 ++++++----- .../components/review/mapping/MappingView.tsx | 81 ++++++++++++++ .../edit-modals/RemoveContentModal.tsx | 30 ++++++ .../review/mapping/MappingView.spec.tsx | 100 +++++++++++++++++- .../mapping/RemoveContentModal.spec.tsx | 48 +++++++++ 6 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.styles.ts create mode 100644 apps/drive-integration/src/locations/Page/components/review/mapping/edit-modals/RemoveContentModal.tsx create mode 100644 apps/drive-integration/test/locations/Page/components/review/mapping/RemoveContentModal.spec.tsx diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.styles.ts b/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.styles.ts new file mode 100644 index 0000000000..2547fa4ff6 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.styles.ts @@ -0,0 +1,60 @@ +import { css } from '@emotion/css'; +import tokens from '@contentful/f36-tokens'; + +export const BUTTON_ESTIMATE_WIDTH_PX = 280; + +const action = css({ + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacing2Xs, + border: 'none', + cursor: 'pointer', + fontSize: tokens.fontSizeS, + fontWeight: tokens.fontWeightMedium, + padding: `${tokens.spacing2Xs} ${tokens.spacingS}`, + whiteSpace: 'nowrap', +}); + +export const editAction = css([ + action, + { + color: tokens.gray900, + backgroundColor: tokens.colorWhite, + '&:hover': { + backgroundColor: tokens.gray200, + }, + }, +]); + +export const removeAction = css([ + action, + { + color: tokens.red600, + backgroundColor: tokens.gray100, + '&:hover': { + backgroundColor: tokens.gray200, + }, + }, +]); + +export const divider = css({ + width: 1, + alignSelf: 'stretch', + backgroundColor: tokens.gray300, +}); + +export const getMenuPosition = (top: number, left: number) => + css({ + display: 'inline-flex', + alignItems: 'stretch', + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.gray300}`, + backgroundColor: tokens.colorWhite, + boxShadow: '0 2px 6px rgba(0, 0, 0, 0.08)', + overflow: 'hidden', + position: 'fixed', + top, + left, + transform: 'translateX(-50%)', + zIndex: 3, + }); diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.tsx index acdede14c0..bea924a767 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/EditMappingButton.tsx @@ -1,49 +1,55 @@ -import { forwardRef } from 'react'; -import { Box, Button } from '@contentful/f36-components'; -import { PencilSimpleIcon } from '@contentful/f36-icons'; +import { forwardRef, type FocusEvent } from 'react'; +import { Box } from '@contentful/f36-components'; +import { PencilSimpleIcon, TrashSimpleIcon } from '@contentful/f36-icons'; import tokens from '@contentful/f36-tokens'; import type { SelectionViewportRectangle } from './selectionViewportRectangle'; +import { + BUTTON_ESTIMATE_WIDTH_PX, + divider, + editAction, + getMenuPosition, + removeAction, +} from './EditMappingButton.styles'; interface EditMappingButtonProps { anchorRectangle: SelectionViewportRectangle; onEdit: () => void; + onRemove?: () => void; onBlur?: () => void; } -const BUTTON_ESTIMATE_WIDTH_PX = 160; - export const EditMappingButton = forwardRef( - ({ anchorRectangle, onEdit, onBlur }, ref) => { + ({ anchorRectangle, onEdit, onRemove, onBlur }, ref) => { const centerX = (anchorRectangle.left + anchorRectangle.right) / 2; const half = BUTTON_ESTIMATE_WIDTH_PX / 2; const clampedCenterX = Math.min(Math.max(centerX, 8 + half), window.innerWidth - 8 - half); + const handleBlur = (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + onBlur?.(); + }; + return ( - + + {onRemove ? ( + <> + ); } diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx index d5b0aaff96..0cf5e549a1 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx @@ -71,6 +71,7 @@ import { collectTextExclusionRangesFromSelection, type TextExclusionRange, } from './entryBlockGraphExclusion'; +import { RemoveContentModal } from './edit-modals/RemoveContentModal'; interface EditModalState { viewModel: EditModalContent; @@ -78,6 +79,20 @@ interface EditModalState { primaryButtonLabel: string; } +interface RemoveModalState { + isOpen: boolean; + locations: EditLocationOption[]; + textRanges: TextExclusionRange[]; + imageRefs: ImageSourceRef[]; +} + +const EMPTY_REMOVE_MODAL: RemoveModalState = { + isOpen: false, + locations: [], + textRanges: [], + imageRefs: [], +}; + interface MappingViewProps { payload: MappingReviewSuspendPayload; entryBlockGraph: EntryBlockGraph; @@ -170,6 +185,7 @@ export const MappingView = ({ Record> >({}); const [editModalState, setEditModalState] = useState(EMPTY_EDIT_MODAL); + const [removeModalState, setRemoveModalState] = useState(EMPTY_REMOVE_MODAL); const [pendingTextExclusionRanges, setPendingTextExclusionRanges] = useState< TextExclusionRange[] | null >(null); @@ -586,6 +602,62 @@ export const MappingView = ({ clearSelection(); }; + const handleRemoveFromSelection = () => { + if (isDisabled || !selectedText.trim()) return; + const selectionRange = selectedRange ? selectedRange.cloneRange() : null; + const textRanges = collectTextExclusionRangesFromSelection( + textSelectionRootRef.current, + selectionRange + ); + const imageRefs = collectRichTextSourceRefsFromSelection( + textSelectionRootRef.current, + selectionRange, + document + ).filter( + (ref): ref is ImageSourceRef => isBlockImageSourceRef(ref) || isTableImageSourceRef(ref) + ); + const locations = getLocationsForSelectedText(); + + if (!locations.length || (!textRanges.length && !imageRefs.length)) { + clearSelection(); + return; + } + + setRemoveModalState({ isOpen: true, locations, textRanges, imageRefs }); + clearSelection(); + }; + + const closeRemoveModal = () => { + setRemoveModalState(EMPTY_REMOVE_MODAL); + }; + + const handleConfirmRemove = () => { + const { locations, textRanges, imageRefs } = removeModalState; + let next = entryBlockGraph; + + for (const location of locations) { + const locationSourceRefKeys = new Set( + (location.sourceRefs?.length ? location.sourceRefs : [location.sourceRef]).map( + buildSourceRefKey + ) + ); + + if (textRanges.length) { + next = applyTextExclusionToEntryBlockGraph(next, location, textRanges); + } + + const matchingImages = imageRefs.filter((ref) => + locationSourceRefKeys.has(buildSourceRefKey(ref)) + ); + for (const imageRef of matchingImages) { + next = applyImageExclusionToEntryBlockGraph(next, location, imageRef); + } + } + + if (next !== entryBlockGraph) onEntryBlockGraphChange(next); + closeRemoveModal(); + }; + const handleEditImage = (sourceRef: ImageSourceRef, label: string) => { if (isDisabled) return; const currentLocations = getLocationsForSourceRef(sourceRef); @@ -951,6 +1023,9 @@ export const MappingView = ({ ref={editButtonRef} anchorRectangle={selectionRectangle} onEdit={handleEditFromSelection} + onRemove={ + getLocationsForSelectedText().length > 0 ? handleRemoveFromSelection : undefined + } onBlur={clearSelection} /> ) : null} @@ -1002,6 +1077,12 @@ export const MappingView = ({ })()} onConfirmPrimary={handleEditModalConfirmPrimary} /> + + ); }; diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/edit-modals/RemoveContentModal.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/edit-modals/RemoveContentModal.tsx new file mode 100644 index 0000000000..549c59abef --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/edit-modals/RemoveContentModal.tsx @@ -0,0 +1,30 @@ +import { Button, Modal, Paragraph } from '@contentful/f36-components'; +interface RemoveContentModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +} +export const RemoveContentModal = ({ isOpen, onConfirm, onCancel }: RemoveContentModalProps) => { + return ( + + {() => ( + <> + + + + Are you sure you'd like to remove this content from the entry? + + + + + + + + )} + + ); +}; diff --git a/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx index 071a771c03..cf5d7b79ca 100644 --- a/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx @@ -1,7 +1,8 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MappingReviewSuspendPayload, SourceRef } from '@types'; import { MappingView } from '../../../../../../src/locations/Page/components/review/mapping/MappingView'; +import React from 'react'; const mockUseReviewTextSelection = vi.fn(); const mockClearSelection = vi.fn(); @@ -1053,4 +1054,101 @@ describe('MappingView', () => { expect(screen.getByRole('heading', { name: 'Edit content mapping' })).toBeTruthy(); expect(screen.getByText('Selected Article: Untitled')).toBeTruthy(); }); + + it('does not offer Remove in the selection menu for unmapped text', () => { + const selectedRange = createDetachedRange('plain text', 0, 5); + mockUseReviewTextSelection.mockReturnValue({ + selectionRectangle: { top: 100, left: 100, right: 160, bottom: 120 }, + selectedText: 'plain text', + selectedRange, + clearSelection: mockClearSelection, + freezeSelection: vi.fn(), + }); + + const payload = createPayload(); + render( + + ); + + const menu = screen.getByTestId('review-selection-menu'); + expect(within(menu).getByRole('button', { name: 'Edit content mapping' })).toBeTruthy(); + expect(within(menu).queryByRole('button', { name: 'Remove' })).toBeNull(); + }); + + it('removes content mapped to multiple fields of the same entry', () => { + const payload = createPayload({ + fieldMappings: [ + { + fieldId: 'body', + fieldType: 'Text', + sourceRefs: [createBlockTextSourceRef('block-1', 'Hello world')], + }, + { + fieldId: 'summary', + fieldType: 'Text', + sourceRefs: [createBlockTextSourceRef('block-1', 'Hello world')], + }, + ], + }); + + let currentGraph = payload.entryBlockGraph; + const onEntryBlockGraphChange = vi.fn( + (nextGraph: MappingReviewSuspendPayload['entryBlockGraph']) => { + currentGraph = nextGraph; + } + ); + + mockUseReviewTextSelection.mockReturnValueOnce({ + selectionRectangle: null, + selectedText: '', + selectedRange: null, + clearSelection: mockClearSelection, + freezeSelection: vi.fn(), + }); + + const { container, rerender } = render( + + ); + + const mappedSegment = container.querySelector('[data-review-text-segment="true"]'); + const selectedRange = createDomRange(mappedSegment?.firstChild as Text, 0, 11); + + mockUseReviewTextSelection.mockReturnValue({ + selectionRectangle: { top: 100, left: 100, right: 160, bottom: 120 }, + selectedText: 'Hello world', + selectedRange, + clearSelection: mockClearSelection, + freezeSelection: vi.fn(), + }); + + rerender( + + ); + + const menu = screen.getByTestId('review-selection-menu'); + fireEvent.click(within(menu).getByRole('button', { name: 'Remove' })); + + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Remove' })); + + expect(onEntryBlockGraphChange).toHaveBeenCalledTimes(1); + expect(currentGraph.entries[0].fieldMappings.map((fm) => fm.fieldId)).toEqual([]); + }); }); diff --git a/apps/drive-integration/test/locations/Page/components/review/mapping/RemoveContentModal.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/mapping/RemoveContentModal.spec.tsx new file mode 100644 index 0000000000..db74938b51 --- /dev/null +++ b/apps/drive-integration/test/locations/Page/components/review/mapping/RemoveContentModal.spec.tsx @@ -0,0 +1,48 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RemoveContentModal } from '../../../../../../src/locations/Page/components/review/mapping/edit-modals/RemoveContentModal'; +import React from 'react'; + +describe('RemoveContentModal', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('does not render when closed', () => { + render(); + + expect(screen.queryByText('Remove content from entry')).toBeNull(); + }); + + it('renders the confirmation copy and controls when open', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Remove content from entry' })).toBeTruthy(); + expect( + screen.getByText("Are you sure you'd like to remove this content from the entry?") + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeTruthy(); + }); + + it('calls onConfirm when Remove is clicked', () => { + const onConfirm = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when Cancel is clicked', () => { + const onCancel = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); From 6e9feee520c03951dfa0835aa5200e75d4b97092 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 11 Jun 2026 11:37:33 -0300 Subject: [PATCH 2/3] refactor(drive-integration): image removal through three dots button --- .../components/review/mapping/MappingView.tsx | 22 ++++++----- .../mapping/NormalizedDocumentSection.tsx | 4 ++ .../review/mapping/ReviewImageAssetCard.tsx | 38 +++++++++++-------- .../review/mapping/documentRenderers.tsx | 29 +++++++------- .../review/mapping/MappingView.spec.tsx | 35 +++++++++++++++++ 5 files changed, 89 insertions(+), 39 deletions(-) diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx index 0cf5e549a1..93b7d7fc0a 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx @@ -609,24 +609,26 @@ export const MappingView = ({ textSelectionRootRef.current, selectionRange ); - const imageRefs = collectRichTextSourceRefsFromSelection( - textSelectionRootRef.current, - selectionRange, - document - ).filter( - (ref): ref is ImageSourceRef => isBlockImageSourceRef(ref) || isTableImageSourceRef(ref) - ); const locations = getLocationsForSelectedText(); - if (!locations.length || (!textRanges.length && !imageRefs.length)) { + if (!locations.length || !textRanges.length) { clearSelection(); return; } - setRemoveModalState({ isOpen: true, locations, textRanges, imageRefs }); + setRemoveModalState({ isOpen: true, locations, textRanges, imageRefs: [] }); clearSelection(); }; + const handleRemoveImage = (sourceRef: ImageSourceRef) => { + if (isDisabled) return; + const locations = getLocationsForSourceRef(sourceRef); + if (!locations.length) return; + + setRemoveModalState({ isOpen: true, locations, textRanges: [], imageRefs: [sourceRef] }); + setHoveredMappingKeys([]); + }; + const closeRemoveModal = () => { setRemoveModalState(EMPTY_REMOVE_MODAL); }; @@ -966,6 +968,7 @@ export const MappingView = ({ isViewMode={isViewMode} onSetHoveredMappingKeys={setHoveredMappingKeys} onEditImage={isViewMode ? undefined : handleEditImage} + onRemoveImage={isViewMode ? undefined : handleRemoveImage} /> ))} @@ -985,6 +988,7 @@ export const MappingView = ({ isViewMode={isViewMode} onSetHoveredMappingKeys={setHoveredMappingKeys} onEditImage={isViewMode ? undefined : handleEditImage} + onRemoveImage={isViewMode ? undefined : handleRemoveImage} /> ))} diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx index 175a5ffcdf..698b6c68cc 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx @@ -19,6 +19,7 @@ interface ReviewDocumentBodyProps { isViewMode: boolean; onSetHoveredMappingKeys: (keys: string[]) => void; onEditImage?: (sourceRef: ImageSourceRef, label: string) => void; + onRemoveImage?: (sourceRef: ImageSourceRef) => void; } export const NormalizedDocumentSection = ({ @@ -33,6 +34,7 @@ export const NormalizedDocumentSection = ({ isViewMode, onSetHoveredMappingKeys, onEditImage, + onRemoveImage, }: ReviewDocumentBodyProps): JSX.Element => { return ( @@ -56,6 +58,7 @@ export const NormalizedDocumentSection = ({ isViewMode={isViewMode} onSetHoveredMappingKeys={onSetHoveredMappingKeys} onEditImage={onEditImage} + onRemoveImage={onRemoveImage} /> ) : ( )} diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx index 641e6be4bb..3665028ccb 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx @@ -1,5 +1,5 @@ import { Box, Card, Flex, Image, MenuItem, Text } from '@contentful/f36-components'; -import { PencilSimpleIcon } from '@contentful/f36-icons'; +import { PencilSimpleIcon, TrashSimpleIcon } from '@contentful/f36-icons'; import tokens from '@contentful/f36-tokens'; import type { ImageSourceRef, NormalizedDocumentImage } from '@types'; import Splitter from '../../mainpage/Splitter'; @@ -18,6 +18,7 @@ export interface ReviewImageAssetCardProps { onMouseEnter?: () => void; onMouseLeave?: () => void; onEdit?: () => void; + onRemove?: () => void; } export function getNormalizedImageDisplayName(image: NormalizedDocumentImage): string { @@ -35,6 +36,7 @@ export function ReviewImageAssetCard({ onMouseEnter, onMouseLeave, onEdit, + onRemove, }: ReviewImageAssetCardProps): JSX.Element { const title = getNormalizedImageDisplayName(image); @@ -56,6 +58,25 @@ export function ReviewImageAssetCard({ : `1px solid ${tokens.gray300}` : `1px solid ${isHighlighted ? 'transparent' : tokens.gray300}`; + const cardActions = [ + onEdit ? ( + + + + Edit content mapping + + + ) : null, + onRemove ? ( + + + + Remove + + + ) : null, + ].filter((action): action is JSX.Element => action !== null); + return ( - - - - Edit content mapping - - , - ] - : undefined - }> + void; - onEditImage?: ( - sourceRef: { type: 'image'; blockId: string; imageId: string }, - label: string - ) => void; + onEditImage?: (sourceRef: ImageSourceRef, label: string) => void; + onRemoveImage?: (sourceRef: ImageSourceRef) => void; } export const BlockRenderer = ({ @@ -172,6 +171,7 @@ export const BlockRenderer = ({ isViewMode, onSetHoveredMappingKeys, onEditImage, + onRemoveImage, }: BlockRendererProps) => { const visibleHighlights = filterByEntry( highlightIndex.blockHighlights[block.id] ?? [], @@ -266,6 +266,9 @@ export const BlockRenderer = ({ ? () => onEditImage(imageSourceRef, image.title ?? image.altText ?? image.id) : undefined } + onRemove={ + highlighted && onRemoveImage ? () => onRemoveImage(imageSourceRef) : undefined + } /> ); @@ -288,17 +291,8 @@ interface TableRendererProps { hoveredMappingKeys: string[]; isViewMode: boolean; onSetHoveredMappingKeys: (keys: string[]) => void; - onEditImage?: ( - sourceRef: { - type: 'tableImage'; - tableId: string; - rowId: string; - cellId: string; - partId: string; - imageId: string; - }, - label: string - ) => void; + onEditImage?: (sourceRef: ImageSourceRef, label: string) => void; + onRemoveImage?: (sourceRef: ImageSourceRef) => void; } interface TablePartRendererProps { @@ -314,6 +308,7 @@ interface TablePartRendererProps { isViewMode: boolean; onSetHoveredMappingKeys: (keys: string[]) => void; onEditImage?: TableRendererProps['onEditImage']; + onRemoveImage?: TableRendererProps['onRemoveImage']; } const TablePartRenderer = ({ @@ -329,6 +324,7 @@ const TablePartRenderer = ({ isViewMode, onSetHoveredMappingKeys, onEditImage, + onRemoveImage, }: TablePartRendererProps) => { if (part.type === 'image') { const image = imageById[part.imageId]; @@ -383,6 +379,7 @@ const TablePartRenderer = ({ ? () => onEditImage(imageSourceRef, image.title ?? image.altText ?? image.id) : undefined } + onRemove={highlighted && onRemoveImage ? () => onRemoveImage(imageSourceRef) : undefined} /> ); @@ -425,6 +422,7 @@ export const TableRenderer = ({ isViewMode, onSetHoveredMappingKeys, onEditImage, + onRemoveImage, }: TableRendererProps) => { const borderIndex = fullHighlightIndex ?? highlightIndex; @@ -488,6 +486,7 @@ export const TableRenderer = ({ isViewMode={isViewMode} onSetHoveredMappingKeys={onSetHoveredMappingKeys} onEditImage={onEditImage} + onRemoveImage={onRemoveImage} /> ); diff --git a/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx index cf5d7b79ca..d5e336daa0 100644 --- a/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/review/mapping/MappingView.spec.tsx @@ -16,15 +16,22 @@ vi.mock( () => ({ ReviewImageAssetCard: ({ onEdit, + onRemove, isHighlighted, }: { onEdit: () => void; + onRemove?: () => void; isHighlighted: boolean; }) => (
+ {onRemove ? ( + + ) : null}
), }) @@ -1055,6 +1062,34 @@ describe('MappingView', () => { expect(screen.getByText('Selected Article: Untitled')).toBeTruthy(); }); + it('removes mapped image from the entry via the image card menu', () => { + const payload = createImagePayload(); + let currentGraph = payload.entryBlockGraph; + const onEntryBlockGraphChange = vi.fn( + (nextGraph: MappingReviewSuspendPayload['entryBlockGraph']) => { + currentGraph = nextGraph; + } + ); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove image' })); + + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Remove' })); + + expect(onEntryBlockGraphChange).toHaveBeenCalledTimes(1); + expect(currentGraph.entries[0].fieldMappings).toHaveLength(0); + }); + it('does not offer Remove in the selection menu for unmapped text', () => { const selectedRange = createDetachedRange('plain text', 0, 5); mockUseReviewTextSelection.mockReturnValue({ From 802dfb8a5ef5195800617fedb7fb8129d2fa1c4b Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 11 Jun 2026 11:56:51 -0300 Subject: [PATCH 3/3] refactor(drive-integration): adding ability to remove images through selection [INTEG-4135] --- .../components/review/mapping/MappingView.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx index 93b7d7fc0a..30ee674e53 100644 --- a/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/mapping/MappingView.tsx @@ -455,16 +455,16 @@ export const MappingView = ({ return matches; }; - const getLocationsForSelectedText = (): EditLocationOption[] => { + const collectMappingKeysFromSelection = (): Set => { const root = textSelectionRootRef.current; if (!root || !selectedRange) { - return []; + return new Set(); } + const mappingKeys = new Set(); const selectedMappedSegments = root.querySelectorAll( - '[data-review-text-segment="true"][data-is-mapped="true"]' + '[data-review-text-segment="true"][data-is-mapped="true"], [data-review-image-segment="true"][data-is-mapped="true"]' ); - const mappingKeys = new Set(); for (const segment of selectedMappedSegments) { if (!rangeIntersectsNode(selectedRange, segment)) { @@ -479,6 +479,15 @@ export const MappingView = ({ .forEach((key) => mappingKeys.add(key)); } + return mappingKeys; + }; + + const getLocationsForSelectedText = (): EditLocationOption[] => { + const mappingKeys = collectMappingKeysFromSelection(); + if (!mappingKeys.size) { + return []; + } + const locations = allGroups .flatMap((group) => group.mappingCards) .filter((card) => card.mappingKeys.some((key) => mappingKeys.has(key))) @@ -609,14 +618,22 @@ export const MappingView = ({ textSelectionRootRef.current, selectionRange ); + const imageRefs = collectRichTextSourceRefsFromSelection( + textSelectionRootRef.current, + selectionRange, + document, + { mappedState: 'mapped' } + ).filter( + (ref): ref is ImageSourceRef => isBlockImageSourceRef(ref) || isTableImageSourceRef(ref) + ); const locations = getLocationsForSelectedText(); - if (!locations.length || !textRanges.length) { + if (!locations.length || (!textRanges.length && !imageRefs.length)) { clearSelection(); return; } - setRemoveModalState({ isOpen: true, locations, textRanges, imageRefs: [] }); + setRemoveModalState({ isOpen: true, locations, textRanges, imageRefs }); clearSelection(); };