diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/EditMappingButton.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/EditMappingButton.tsx new file mode 100644 index 0000000000..abf4c697a4 --- /dev/null +++ b/apps/google-docs/src/locations/Page/components/review/mapping/EditMappingButton.tsx @@ -0,0 +1,50 @@ +import { forwardRef } from 'react'; +import { Box, Button } from '@contentful/f36-components'; +import { PencilSimpleIcon } from '@contentful/f36-icons'; +import tokens from '@contentful/f36-tokens'; +import type { SelectionViewportRectangle } from './selectionViewportRectangle'; + +interface EditMappingButtonProps { + anchorRectangle: SelectionViewportRectangle; + onEdit: () => void; +} + +const BUTTON_ESTIMATE_WIDTH_PX = 160; + +export const EditMappingButton = forwardRef( + ({ anchorRectangle, onEdit }, 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); + + return ( + + + + ); + } +); + +EditMappingButton.displayName = 'EditMappingButton'; diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/MappingEntryCards.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/MappingEntryCards.tsx index 1626714453..82e2ecadb2 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/MappingEntryCards.tsx +++ b/apps/google-docs/src/locations/Page/components/review/mapping/MappingEntryCards.tsx @@ -7,6 +7,7 @@ interface MappingEntryCardsProps { groupId: string; mappingCards: RenderedMappingCard[]; cardOffsetsByGroup: Record>; + cardHeightsByGroup: Record>; hoveredMappingKeys: string[]; onSetHoveredMappingKeys: (keys: string[]) => void; setCardWrapperRef: (cardKey: string) => RefCallback; @@ -22,13 +23,21 @@ export const MappingEntryCards = ({ groupId, mappingCards, cardOffsetsByGroup, + cardHeightsByGroup, hoveredMappingKeys, onSetHoveredMappingKeys, setCardWrapperRef, }: MappingEntryCardsProps): JSX.Element => { + const offsets = cardOffsetsByGroup[groupId] ?? {}; + const heights = cardHeightsByGroup[groupId] ?? {}; + const minHeight = Math.max( + 0, + ...mappingCards.map((card) => (offsets[card.key] ?? 0) + (heights[card.key] ?? 28)) + ); + return ( - + {mappingCards.length > 0 ? mappingCards.map((mappingCard) => ( > >({}); + const [cardHeightsByGroup, setCardHeightsByGroup] = useState< + Record> + >({}); const [editModalState, setEditModalState] = useState(EMPTY_EDIT_MODAL); const [pendingTextExclusionRanges, setPendingTextExclusionRanges] = useState< TextExclusionRange[] | null >(null); - const [pendingImageSourceRef, setPendingImageSourceRef] = useState(null); - const [pendingImageReassignSourceRef, setPendingImageReassignSourceRef] = - useState(null); const [pendingExcludeImageSourceRefs, setPendingExcludeImageSourceRefs] = useState< ImageSourceRef[] >([]); - const [pendingTextReassignRanges, setPendingTextReassignRanges] = useState( - [] - ); const [pendingTextAssignRanges, setPendingTextAssignRanges] = useState([]); const [pendingModalSelectionRange, setPendingModalSelectionRange] = useState(null); const [pendingPreviewSourceRefs, setPendingPreviewSourceRefs] = useState([]); @@ -173,10 +155,7 @@ export const MappingView = ({ const closeEditModal = () => { setEditModalState(EMPTY_EDIT_MODAL); setPendingTextExclusionRanges(null); - setPendingImageSourceRef(null); - setPendingImageReassignSourceRef(null); setPendingExcludeImageSourceRefs([]); - setPendingTextReassignRanges([]); setPendingTextAssignRanges([]); setPendingModalSelectionRange(null); setPendingPreviewSourceRefs([]); @@ -385,6 +364,7 @@ export const MappingView = ({ fieldId: fieldMapping.fieldId, sourceRefs: fieldMapping.sourceRefs, })), + initialFieldIds: [], }; }; @@ -481,6 +461,7 @@ export const MappingView = ({ useLayoutEffect(() => { const measureOffsets = () => { const nextOffsets: Record> = {}; + const nextHeights: Record> = {}; allGroups.forEach((group) => { const groupNode = groupLayoutRefs.current[group.id]; @@ -505,92 +486,27 @@ export const MappingView = ({ }); nextOffsets[group.id] = resolveMarkerOffsets(cards); + nextHeights[group.id] = Object.fromEntries(cards.map((c) => [c.key, c.height])); }); setCardOffsetsByGroup(nextOffsets); + setCardHeightsByGroup(nextHeights); }; measureOffsets(); - }, [allGroups]); - const openAssignModal = ( - preview: string, - currentLocations: EditLocationOption[], - reassignRanges: TextExclusionRange[], - assignRanges: TextExclusionRange[], - imageSourceRef: ImageSourceRef | null = null, - selectionRange: Range | null = null, - previewSourceRefs: SourceRef[] = [] - ) => { - setPendingTextExclusionRanges(null); - setPendingImageSourceRef(null); - setPendingImageReassignSourceRef(imageSourceRef); - setPendingTextReassignRanges(reassignRanges); - setPendingTextAssignRanges(assignRanges); - setPendingModalSelectionRange(selectionRange ? selectionRange.cloneRange() : null); - setPendingPreviewSourceRefs(previewSourceRefs); - setPendingPreviewHasTableContent( - selectionRange - ? selectionIncludesTableContent(textSelectionRootRef.current, selectionRange) - : false - ); - setEditModalState({ - mode: 'assign', - viewModel: { - selectedText: preview, - currentLocations, - newLocation, - isOpen: true, - }, - title: `${canExcludeSelectedText ? 'Reassign' : 'Assign'} content`, - locationSectionDescription: '', - primaryButtonLabel: `${canExcludeSelectedText ? 'Reassign' : 'Assign'} content`, + const observer = new ResizeObserver(measureOffsets); + Object.values(cardWrapperRefs.current).forEach((node) => { + if (node) observer.observe(node); }); - }; - const openExcludeModal = ( - selectedText: string, - currentLocations: EditLocationOption[], - preview?: { contentPreview: string; previewSectionTitle: string }, - selectionRange: Range | null = null, - previewSourceRefs: SourceRef[] = [] - ) => { - setPendingExcludeImageSourceRefs( - previewSourceRefs.filter( - (sourceRef): sourceRef is ImageSourceRef => - isBlockImageSourceRef(sourceRef) || isTableImageSourceRef(sourceRef) - ) - ); - setPendingModalSelectionRange(selectionRange ? selectionRange.cloneRange() : null); - setPendingPreviewSourceRefs(previewSourceRefs); - setPendingPreviewHasTableContent( - selectionRange - ? selectionIncludesTableContent(textSelectionRootRef.current, selectionRange) - : false - ); - setEditModalState({ - mode: 'exclude', - viewModel: { - selectedText, - contentPreview: preview?.contentPreview, - previewSectionTitle: preview?.previewSectionTitle, - currentLocations, - newLocation: EMPTY_NEW_LOCATION, - isOpen: true, - }, - title: 'Exclude content', - locationSectionDescription: - currentLocations.length > 1 - ? 'This content is used in more than one place in the entry. Select which item to exclude.' - : '', - primaryButtonLabel: 'Exclude content', - }); - }; + return () => observer.disconnect(); + }, [allGroups]); - const handleAssignFromSelection = () => { + const handleEditFromSelection = () => { if (isDisabled || !selectedText.trim()) return; const selectionRange = selectedRange ? selectedRange.cloneRange() : null; - const reassignRanges = collectTextExclusionRangesFromSelection( + const exclusionRanges = collectTextExclusionRangesFromSelection( textSelectionRootRef.current, selectionRange ); @@ -603,222 +519,130 @@ export const MappingView = ({ selectionRange, document ); - openAssignModal( - selectedText.trim(), - getLocationsForSelectedText(), - reassignRanges, - assignRanges, - null, - selectionRange, - previewSourceRefs + const currentLocations = getLocationsForSelectedText(); + + const currentSourceRefKeys = new Set( + currentLocations.flatMap((loc) => + (loc.sourceRefs?.length ? loc.sourceRefs : [loc.sourceRef]).map(buildSourceRefKey) + ) ); - clearSelection(); - }; + const initialFieldIds = newLocation.fieldMappings + .filter((fm) => fm.sourceRefs.some((sr) => currentSourceRefKeys.has(buildSourceRefKey(sr)))) + .map((fm) => fm.fieldId); - const handleExcludeFromSelection = () => { - if (isDisabled || !selectedText.trim()) return; - const selectionRange = selectedRange ? selectedRange.cloneRange() : null; - const ranges = collectTextExclusionRangesFromSelection( - textSelectionRootRef.current, - selectionRange + setPendingTextExclusionRanges(exclusionRanges.length ? exclusionRanges : null); + setPendingTextAssignRanges(assignRanges); + setPendingModalSelectionRange(selectionRange); + setPendingPreviewSourceRefs(previewSourceRefs); + setPendingPreviewHasTableContent( + selectionIncludesTableContent(textSelectionRootRef.current, selectionRange) ); - setPendingTextExclusionRanges(ranges.length ? ranges : null); - setPendingImageSourceRef(null); - const trimmed = selectedText.trim(); - const mappedPreview = collectMappedExclusionPreviewText( - textSelectionRootRef.current, - selectionRange - ).trim(); - const previewSourceRefs = collectRichTextSourceRefsFromSelection( - textSelectionRootRef.current, - selectionRange, - document, - { mappedState: 'mapped' } + setPendingExcludeImageSourceRefs( + previewSourceRefs.filter( + (ref): ref is ImageSourceRef => isBlockImageSourceRef(ref) || isTableImageSourceRef(ref) + ) ); - openExcludeModal( - trimmed, - getLocationsForSelectedText(), - { - contentPreview: mappedPreview || trimmed, - previewSectionTitle: 'Selected content', + + setEditModalState({ + viewModel: { + selectedText: selectedText.trim(), + isImageContent: false, + currentLocations, + newLocation: { ...newLocation, initialFieldIds }, + isOpen: true, }, - selectionRange, - previewSourceRefs - ); + title: 'Edit content mapping', + primaryButtonLabel: 'Apply', + }); clearSelection(); }; - const handleAssignImage = (sourceRef: ImageSourceRef, label: string) => { + const handleEditImage = (sourceRef: ImageSourceRef, label: string) => { if (isDisabled) return; - openAssignModal(label, getLocationsForSourceRef(sourceRef), [], [], sourceRef); - setHoveredMappingKeys([]); - }; + const currentLocations = getLocationsForSourceRef(sourceRef); + + const currentSourceRefKeys = new Set( + currentLocations.flatMap((loc) => + (loc.sourceRefs?.length ? loc.sourceRefs : [loc.sourceRef]).map(buildSourceRefKey) + ) + ); + const initialFieldIds = newLocation.fieldMappings + .filter((fm) => fm.sourceRefs.some((sr) => currentSourceRefKeys.has(buildSourceRefKey(sr)))) + .map((fm) => fm.fieldId); - const handleExcludeImage = (sourceRef: ImageSourceRef, label: string) => { - if (isDisabled) return; - setPendingImageSourceRef(sourceRef); setPendingTextExclusionRanges(null); - openExcludeModal(label, getLocationsForSourceRef(sourceRef), { - contentPreview: label, - previewSectionTitle: 'Image to exclude', + setPendingTextAssignRanges([]); + setPendingModalSelectionRange(null); + setPendingPreviewSourceRefs([]); + setPendingPreviewHasTableContent(false); + setPendingExcludeImageSourceRefs([sourceRef]); + + setEditModalState({ + viewModel: { + selectedText: label, + isImageContent: true, + currentLocations, + newLocation: { ...newLocation, initialFieldIds }, + isOpen: true, + }, + title: 'Edit content mapping', + primaryButtonLabel: 'Apply', }); setHoveredMappingKeys([]); }; - const handleEditModalConfirmPrimary = ({ - selectedLocationIds = [], - selectedFieldIds = {}, - }: { - selectedLocationIds?: string[]; - selectedFieldIds?: Record; - }) => { - if (editModalState.mode === 'assign') { - const locations = editModalState.viewModel.currentLocations; - - const newLocation = editModalState.viewModel.newLocation; - const resolvedTargets: { entryIndex: number; fieldId: string; fieldType: string }[] = []; - - if (newLocation && selectedEntryIndex !== null) { - const entry = entryBlockGraph.entries[selectedEntryIndex]; - const fieldIds = selectedFieldIds[newLocation.id] ?? []; - const contentType = entry - ? payload.contentTypes.find((c) => c.sys.id === entry.contentTypeId) - : undefined; - - for (const fieldId of fieldIds) { - const field = contentType?.fields?.find((f) => 'id' in f && f.id === fieldId); - const fieldType = - field && 'type' in field && typeof field.type === 'string' ? field.type : 'Text'; - if (!fieldId.length) { - continue; - } + const handleEditModalConfirmPrimary = (selectedFieldIds: string[]) => { + const initialFieldIds = editModalState.viewModel.newLocation.initialFieldIds; + const addedFieldIds = selectedFieldIds.filter((id) => !initialFieldIds.includes(id)); + const removedFieldIds = initialFieldIds.filter((id) => !selectedFieldIds.includes(id)); + const currentLocations = editModalState.viewModel.currentLocations; - resolvedTargets.push({ - entryIndex: selectedEntryIndex, - fieldId, - fieldType, - }); - } - } + let next = entryBlockGraph; + + for (const fieldId of removedFieldIds) { + const loc = currentLocations.find((l) => l.fieldId === fieldId); + if (!loc) continue; - if (!resolvedTargets.length) { - return; + if (pendingTextExclusionRanges?.length) { + next = applyTextExclusionToEntryBlockGraph(next, loc, pendingTextExclusionRanges); } - const richTextTargets = resolvedTargets.filter((target) => target.fieldType === 'RichText'); - const nonRichTextTargets = resolvedTargets.filter( - (target) => target.fieldType !== 'RichText' + const locSourceRefKeys = new Set( + (loc.sourceRefs?.length ? loc.sourceRefs : [loc.sourceRef]).map(buildSourceRefKey) ); - - if (pendingImageReassignSourceRef) { - if (locations.length === 0) { - onEntryBlockGraphChange( - appendImageToTargets(entryBlockGraph, pendingImageReassignSourceRef, resolvedTargets) - ); - closeEditModal(); - return; - } - - const from = - locations.find((location) => location.id === selectedLocationIds[0]) ?? - locations.find((location) => location.isSelected) ?? - locations[0]; - - if (!from) { - closeEditModal(); - return; - } - - onEntryBlockGraphChange( - applyImageReassignToEntryBlockGraph( - entryBlockGraph, - from, - pendingImageReassignSourceRef, - resolvedTargets - ) - ); - closeEditModal(); - return; + const matchingImages = pendingExcludeImageSourceRefs.filter((ref) => + locSourceRefKeys.has(buildSourceRefKey(ref)) + ); + for (const imgRef of matchingImages) { + next = applyImageExclusionToEntryBlockGraph(next, loc, imgRef); } + } - if (locations.length > 0) { - const from = - locations.find((location) => location.id === selectedLocationIds[0]) ?? - locations.find((location) => location.isSelected) ?? - locations[0]; - - if (!from) { - closeEditModal(); - return; - } - - let nextGraph = entryBlockGraph; - - if (richTextTargets.length && pendingModalSelectionRange) { - const mappedRichTextRefs = collectRichTextSourceRefsFromSelection( - textSelectionRootRef.current, - pendingModalSelectionRange, - document, - { - mappedState: 'mapped', - mappingKeys: new Set(from.mappingKeys ?? []), - } - ); - const unmappedRichTextRefs = collectRichTextSourceRefsFromSelection( - textSelectionRootRef.current, - pendingModalSelectionRange, - document, - { mappedState: 'unmapped' } - ); - const richTextRefs = [...mappedRichTextRefs, ...unmappedRichTextRefs]; - - if (richTextRefs.length) { - nextGraph = applyRichTextReassignToEntryBlockGraph( - nextGraph, - document, - from, - richTextRefs, - richTextTargets - ); - } - } - - if (!nonRichTextTargets.length) { - onEntryBlockGraphChange(nextGraph); - closeEditModal(); - return; - } - - const effectiveRanges = pendingTextReassignRanges.length - ? pendingTextReassignRanges - : fullSpanTextExclusionRangesForLocation(from); - - if (!effectiveRanges.length) { - onEntryBlockGraphChange(nextGraph); - closeEditModal(); - return; - } - - onEntryBlockGraphChange( - applyTextReassignToEntryBlockGraph(nextGraph, from, effectiveRanges, nonRichTextTargets) - ); - closeEditModal(); - return; - } + if (addedFieldIds.length && selectedEntryIndex !== null) { + const entry = entryBlockGraph.entries[selectedEntryIndex]; + const contentType = entry + ? payload.contentTypes.find((c) => c.sys.id === entry.contentTypeId) + : undefined; + + const resolvedTargets = addedFieldIds.flatMap((fieldId) => { + const field = contentType?.fields?.find((f) => 'id' in f && f.id === fieldId); + const fieldType = + field && 'type' in field && typeof field.type === 'string' ? field.type : 'Text'; + return [{ entryIndex: selectedEntryIndex, fieldId, fieldType }]; + }); - let nextGraph = entryBlockGraph; + const richTextTargets = resolvedTargets.filter((t) => t.fieldType === 'RichText'); + const nonRichTextTargets = resolvedTargets.filter((t) => t.fieldType !== 'RichText'); if (richTextTargets.length && pendingModalSelectionRange) { const richTextRefs = collectRichTextSourceRefsFromSelection( textSelectionRootRef.current, pendingModalSelectionRange, - document, - { mappedState: 'unmapped' } + document ); - if (richTextRefs.length) { - nextGraph = applyRichTextAssignToEntryBlockGraph( - nextGraph, + next = applyRichTextAssignToEntryBlockGraph( + next, document, richTextRefs, richTextTargets @@ -826,93 +650,24 @@ export const MappingView = ({ } } - if (!nonRichTextTargets.length) { - onEntryBlockGraphChange(nextGraph); - closeEditModal(); - return; - } - - if (!pendingTextAssignRanges.length) { - onEntryBlockGraphChange(nextGraph); - closeEditModal(); - return; - } - - onEntryBlockGraphChange( - applyTextAssignToEntryBlockGraph( - nextGraph, + const allRangesForAssign = [ + ...(pendingTextExclusionRanges ?? []), + ...pendingTextAssignRanges, + ]; + if (nonRichTextTargets.length && allRangesForAssign.length) { + next = applyTextAssignToEntryBlockGraph( + next, document, - pendingTextAssignRanges, + allRangesForAssign, nonRichTextTargets - ) - ); - closeEditModal(); - return; - } - - if (editModalState.mode !== 'exclude') { - closeEditModal(); - return; - } - - const locations = editModalState.viewModel.currentLocations; - const selected = locations.filter((location) => selectedLocationIds.includes(location.id)); - - if (!selected.length) { - closeEditModal(); - return; - } - - let next = entryBlockGraph; - for (const location of selected) { - if (pendingImageSourceRef) { - next = applyImageExclusionToEntryBlockGraph(next, location, pendingImageSourceRef); - } else { - const selectedSourceRefKeys = new Set( - (location.sourceRefs?.length ? location.sourceRefs : [location.sourceRef]).map( - (sourceRef) => buildSourceRefKey(sourceRef) - ) ); - const matchingImageSourceRefs = pendingExcludeImageSourceRefs.filter((sourceRef) => - selectedSourceRefKeys.has(buildSourceRefKey(sourceRef)) - ); - - if (pendingTextExclusionRanges?.length) { - next = applyTextExclusionToEntryBlockGraph(next, location, pendingTextExclusionRanges); - } - - if (matchingImageSourceRefs.length) { - next = matchingImageSourceRefs.reduce( - (graph, sourceRef) => applyImageExclusionToEntryBlockGraph(graph, location, sourceRef), - next - ); - } } } - if (next !== entryBlockGraph) { - onEntryBlockGraphChange(next); - } - + if (next !== entryBlockGraph) onEntryBlockGraphChange(next); closeEditModal(); }; - const canExcludeSelectedText = useMemo(() => { - const root = textSelectionRootRef.current; - if (!root || !selectedRange) { - return false; - } - - const selectedSegments = root.querySelectorAll( - '[data-review-text-segment="true"]' - ); - - return Array.from(selectedSegments).some( - (segment) => - rangeIntersectsNode(selectedRange, segment) && segment.dataset.isMapped === 'true' - ); - }, [selectedRange]); - return ( <> ))} @@ -1013,8 +767,7 @@ export const MappingView = ({ selectedEntryIndex={selectedEntryIndex} hoveredMappingKeys={hoveredMappingKeys} onSetHoveredMappingKeys={setHoveredMappingKeys} - onAssignImage={handleAssignImage} - onExcludeImage={handleExcludeImage} + onEditImage={handleEditImage} /> ))} @@ -1025,6 +778,7 @@ export const MappingView = ({ groupId={group.id} mappingCards={group.mappingCards} cardOffsetsByGroup={cardOffsetsByGroup} + cardHeightsByGroup={cardHeightsByGroup} hoveredMappingKeys={hoveredMappingKeys} onSetHoveredMappingKeys={setHoveredMappingKeys} setCardWrapperRef={setCardWrapperRef} @@ -1039,22 +793,14 @@ export const MappingView = ({ {selectionRectangle && !isDisabled ? ( - + ) : null} { if (!pendingPreviewSourceRefs.length && !pendingPreviewHasTableContent) return undefined; diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx index fa185bbc1a..6a9db68a2f 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx +++ b/apps/google-docs/src/locations/Page/components/review/mapping/NormalizedDocumentSection.tsx @@ -15,8 +15,7 @@ interface ReviewDocumentBodyProps { selectedEntryIndex: number | null; hoveredMappingKeys: string[]; onSetHoveredMappingKeys: (keys: string[]) => void; - onAssignImage: (sourceRef: ImageSourceRef, label: string) => void; - onExcludeImage: (sourceRef: ImageSourceRef, label: string) => void; + onEditImage: (sourceRef: ImageSourceRef, label: string) => void; } export const NormalizedDocumentSection = ({ @@ -28,8 +27,7 @@ export const NormalizedDocumentSection = ({ selectedEntryIndex, hoveredMappingKeys, onSetHoveredMappingKeys, - onAssignImage, - onExcludeImage, + onEditImage, }: ReviewDocumentBodyProps): JSX.Element => { return ( @@ -50,8 +48,7 @@ export const NormalizedDocumentSection = ({ selectedEntryIndex={selectedEntryIndex} hoveredMappingKeys={hoveredMappingKeys} onSetHoveredMappingKeys={onSetHoveredMappingKeys} - onAssignImage={onAssignImage} - onExcludeImage={onExcludeImage} + onEditImage={onEditImage} /> ) : ( )} diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx index a8a3cf6573..92f22cbe59 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx +++ b/apps/google-docs/src/locations/Page/components/review/mapping/ReviewImageAssetCard.tsx @@ -14,8 +14,7 @@ export interface ReviewImageAssetCardProps { size?: 'small' | 'default'; onMouseEnter?: () => void; onMouseLeave?: () => void; - onAssign: () => void; - onExclude: () => void; + onEdit: () => void; } export function getNormalizedImageDisplayName(image: NormalizedDocumentImage): string { @@ -31,11 +30,9 @@ export function ReviewImageAssetCard({ size = 'default', onMouseEnter, onMouseLeave, - onAssign, - onExclude, + onEdit, }: ReviewImageAssetCardProps): JSX.Element { const title = getNormalizedImageDisplayName(image); - const assignLabel = isHighlighted ? 'Reassign' : 'Assign'; const imageHeight = size === 'small' ? '180px' : '280px'; @@ -69,11 +66,8 @@ export function ReviewImageAssetCard({ - {assignLabel} - , - - Exclude + + Edit content mapping , ]}> diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/SelectionActionMenu.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/SelectionActionMenu.tsx deleted file mode 100644 index 68a788cdde..0000000000 --- a/apps/google-docs/src/locations/Page/components/review/mapping/SelectionActionMenu.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { forwardRef } from 'react'; -import { Box, Button, Flex } from '@contentful/f36-components'; -import tokens from '@contentful/f36-tokens'; -import Splitter from '../../mainpage/Splitter'; -import type { SelectionViewportRectangle } from './selectionViewportRectangle'; - -interface SelectionActionMenuProps { - anchorRectangle: SelectionViewportRectangle; - onAssign: () => void; - onExclude: () => void; - isMappedContent: boolean; -} - -const MENU_ESTIMATE_WIDTH_PX = 180; - -export const SelectionActionMenu = forwardRef( - ({ anchorRectangle, onAssign, onExclude, isMappedContent }, ref) => { - const centerX = (anchorRectangle.left + anchorRectangle.right) / 2; - const half = MENU_ESTIMATE_WIDTH_PX / 2; - const clampedCenterX = Math.min(Math.max(centerX, 8 + half), window.innerWidth - 8 - half); - - return ( - - - - - - - - ); - } -); - -SelectionActionMenu.displayName = 'SelectionActionMenu'; diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/documentRenderers.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/documentRenderers.tsx index 079290e84c..a023a9718b 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/documentRenderers.tsx +++ b/apps/google-docs/src/locations/Page/components/review/mapping/documentRenderers.tsx @@ -145,11 +145,7 @@ interface BlockRendererProps { selectedEntryIndex: number | null; hoveredMappingKeys: string[]; onSetHoveredMappingKeys: (keys: string[]) => void; - onAssignImage: ( - sourceRef: { type: 'image'; blockId: string; imageId: string }, - label: string - ) => void; - onExcludeImage: ( + onEditImage: ( sourceRef: { type: 'image'; blockId: string; imageId: string }, label: string ) => void; @@ -165,8 +161,7 @@ export const BlockRenderer = ({ selectedEntryIndex, hoveredMappingKeys, onSetHoveredMappingKeys, - onAssignImage, - onExcludeImage, + onEditImage, }: BlockRendererProps) => { const visibleHighlights = filterByEntry( highlightIndex.blockHighlights[block.id] ?? [], @@ -254,12 +249,7 @@ export const BlockRenderer = ({ highlighted ? () => onSetHoveredMappingKeys(imageMappingKeys) : undefined } onMouseLeave={highlighted ? () => onSetHoveredMappingKeys([]) : undefined} - onAssign={() => - onAssignImage(imageSourceRef, image.title ?? image.altText ?? image.id) - } - onExclude={() => - onExcludeImage(imageSourceRef, image.title ?? image.altText ?? image.id) - } + onEdit={() => onEditImage(imageSourceRef, image.title ?? image.altText ?? image.id)} /> ); @@ -279,18 +269,7 @@ interface TableRendererProps { selectedEntryIndex: number | null; hoveredMappingKeys: string[]; onSetHoveredMappingKeys: (keys: string[]) => void; - onAssignImage: ( - sourceRef: { - type: 'tableImage'; - tableId: string; - rowId: string; - cellId: string; - partId: string; - imageId: string; - }, - label: string - ) => void; - onExcludeImage: ( + onEditImage: ( sourceRef: { type: 'tableImage'; tableId: string; @@ -314,8 +293,7 @@ interface TablePartRendererProps { excludedSourceRefs: SourceRef[]; hoveredMappingKeys: string[]; onSetHoveredMappingKeys: (keys: string[]) => void; - onAssignImage: TableRendererProps['onAssignImage']; - onExcludeImage: TableRendererProps['onExcludeImage']; + onEditImage: TableRendererProps['onEditImage']; } const TablePartRenderer = ({ @@ -329,8 +307,7 @@ const TablePartRenderer = ({ excludedSourceRefs, hoveredMappingKeys, onSetHoveredMappingKeys, - onAssignImage, - onExcludeImage, + onEditImage, }: TablePartRendererProps) => { if (part.type === 'image') { const image = imageById[part.imageId]; @@ -379,8 +356,7 @@ const TablePartRenderer = ({ size="small" onMouseEnter={highlighted ? () => onSetHoveredMappingKeys(mappingKeys) : undefined} onMouseLeave={highlighted ? () => onSetHoveredMappingKeys([]) : undefined} - onAssign={() => onAssignImage(imageSourceRef, image.title ?? image.altText ?? image.id)} - onExclude={() => onExcludeImage(imageSourceRef, image.title ?? image.altText ?? image.id)} + onEdit={() => onEditImage(imageSourceRef, image.title ?? image.altText ?? image.id)} /> ); @@ -419,8 +395,7 @@ export const TableRenderer = ({ selectedEntryIndex, hoveredMappingKeys, onSetHoveredMappingKeys, - onAssignImage, - onExcludeImage, + onEditImage, }: TableRendererProps) => { const getVisiblePartHighlights = (partKey: string) => filterByEntry(highlightIndex.tablePartHighlights[partKey] ?? [], selectedEntryIndex); @@ -463,8 +438,7 @@ export const TableRenderer = ({ excludedSourceRefs={excludedSourceRefs} hoveredMappingKeys={hoveredMappingKeys} onSetHoveredMappingKeys={onSetHoveredMappingKeys} - onAssignImage={onAssignImage} - onExcludeImage={onExcludeImage} + onEditImage={onEditImage} /> ); diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.styles.ts b/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.styles.ts index 2bb3d23f90..b8c0e8f1f1 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.styles.ts +++ b/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.styles.ts @@ -16,36 +16,3 @@ export const sectionCard = css({ borderRadius: tokens.borderRadiusSmall, padding: tokens.spacingS, }); - -export const locationList = css({ - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingS, -}); - -export const locationButton = css({ - appearance: 'none', - width: '100%', - borderRadius: tokens.borderRadiusSmall, - padding: tokens.spacingXs, - backgroundColor: tokens.gray100, - textAlign: 'left', - cursor: 'pointer', - transition: 'border-color 120ms ease, background-color 120ms ease', - ':focus-visible': { - outline: `2px solid ${tokens.blue500}`, - outlineOffset: '2px', - }, -}); - -export const locationButtonSelected = css({ - border: `2px solid ${tokens.blue500}`, -}); - -export const locationButtonUnselected = css({ - border: `1px solid ${tokens.gray200}`, -}); - -export const locationContent = css({ - minWidth: 0, -}); diff --git a/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.tsx b/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.tsx index 26ce14375e..9bff69c361 100644 --- a/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.tsx +++ b/apps/google-docs/src/locations/Page/components/review/mapping/edit-modals/EditModal.tsx @@ -1,56 +1,33 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Box, Button, Modal, Flex, Text } from '@contentful/f36-components'; -import { cx } from '@emotion/css'; import { type EditModalContent } from '@types'; -import { - locationButton, - locationButtonSelected, - locationButtonUnselected, - locationContent, - locationList, - modalContent, - modalContentWithDropdown, - sectionCard, -} from './EditModal.styles'; +import { modalContent, modalContentWithDropdown, sectionCard } from './EditModal.styles'; import { FieldSelectionDropdown } from './FieldSelectionDropdown'; interface EditModalProps { isOpen: boolean; onClose: () => void; - mode: 'assign' | 'exclude' | null; viewModel: EditModalContent; - isImageContent?: boolean; title: string; - locationSectionDescription: string; primaryButtonLabel: string; additionalContent?: ReactNode; - onConfirmPrimary?: (details: { - selectedLocationIds: string[]; - selectedFieldIds: Record; - }) => void; + onConfirmPrimary?: (selectedFieldIds: string[]) => void; } export const EditModal = ({ isOpen, onClose, - mode, viewModel, - isImageContent = false, title, - locationSectionDescription, primaryButtonLabel, additionalContent, onConfirmPrimary, }: EditModalProps) => { - const hasLocationSectionDescription = locationSectionDescription.trim().length > 0; - const hasCurrentLocations = viewModel.currentLocations.length > 0; const hasNewLocation = viewModel.newLocation.id !== ''; - const [selectedLocationIds, setSelectedLocationIds] = useState([]); - - const [selectedFieldIds, setSelectedFieldIds] = useState( - () => viewModel.newLocation.selectedFieldIds ?? [] + const [selectedFieldIds, setSelectedFieldIds] = useState( + () => viewModel.newLocation.initialFieldIds ); const [destinationFieldState, setDestinationFieldState] = useState<{ @@ -60,12 +37,11 @@ export const EditModal = ({ const newLocationRowIdsKey = viewModel.newLocation.id; - // Reset both location and field selections when the modal opens or when the available locations/destination change. + // Reset field selections when the modal opens or when the destination entry changes. // Do not depend on `viewModel.newLocation` reference (it can churn without semantic change). useEffect(() => { if (!isOpen) return; - setSelectedLocationIds([]); - setSelectedFieldIds(viewModel.newLocation.selectedFieldIds ?? []); + setSelectedFieldIds(viewModel.newLocation.initialFieldIds); setDestinationFieldState(null); // eslint-disable-next-line react-hooks/exhaustive-deps -- sync from props only on open / row-id set change, not array identity }, [isOpen, newLocationRowIdsKey]); @@ -75,37 +51,27 @@ export const EditModal = ({ }; const handlePrimaryAction = () => { - onConfirmPrimary?.({ - selectedLocationIds, - selectedFieldIds: { - [viewModel.newLocation.id]: [...selectedFieldIds], - }, - }); + onConfirmPrimary?.([...selectedFieldIds]); }; const previewSectionTitle = viewModel.previewSectionTitle ?? 'Selected content'; const previewQuotedText = (viewModel.contentPreview ?? viewModel.selectedText).trim(); - const selectedDestinationCount = selectedFieldIds.length; - const isAssignMode = mode === 'assign'; - const isPrimaryDisabled = useMemo( - () => - (hasCurrentLocations && selectedLocationIds.length === 0) || // no current location selected yet - (isAssignMode && - (!hasNewLocation || // no destination entry available - viewModel.newLocation.fieldOptions.length === 0 || // destination entry has no fields - destinationFieldState?.hasSelectableOptions === false || // no fields compatible with this content type - selectedDestinationCount === 0)), // user hasn't selected a destination field yet - [ - hasCurrentLocations, - selectedLocationIds, - isAssignMode, - hasNewLocation, - viewModel.newLocation.fieldOptions.length, - destinationFieldState?.hasSelectableOptions, - selectedDestinationCount, - ] - ); + const isPrimaryDisabled = useMemo(() => { + if (!hasNewLocation || viewModel.newLocation.fieldOptions.length === 0) return true; + if (destinationFieldState?.hasSelectableOptions === false) return true; + const initial = viewModel.newLocation.initialFieldIds; + const unchanged = + selectedFieldIds.length === initial.length && + selectedFieldIds.every((id) => initial.includes(id)); + return unchanged; + }, [ + hasNewLocation, + viewModel.newLocation.fieldOptions.length, + viewModel.newLocation.initialFieldIds, + destinationFieldState?.hasSelectableOptions, + selectedFieldIds, + ]); return ( @@ -113,14 +79,14 @@ export const EditModal = ({ <> - + {previewSectionTitle} {additionalContent ?? - (isImageContent ? ( + (viewModel.isImageContent ? ( IMAGE: {previewQuotedText} @@ -131,113 +97,23 @@ export const EditModal = ({ - - - Current location - {hasLocationSectionDescription && ( - - {locationSectionDescription} - - )} - {hasCurrentLocations && ( - - {viewModel.currentLocations.map((location) => { - const isSelected = selectedLocationIds.includes(location.id); - return ( - - setSelectedLocationIds((prev) => - prev.includes(location.id) - ? prev.filter((id) => id !== location.id) - : [...prev, location.id] - ) - } - aria-pressed={isSelected} - className={`${locationButton} ${ - isSelected ? locationButtonSelected : locationButtonUnselected - }`}> - - - Content type{' '} - - {location.contentTypeName} - - - - Entry name{' '} - - {location.entryName} - - - - Field{' '} - - {location.fieldName} - {' '} - - | {location.fieldType} - - - - - ); - })} - - )} - - - {hasNewLocation && ( - + - New location + Assign to fields - - - - {viewModel.newLocation.title} - - - Fields - - - - + + {viewModel.newLocation.title} + + )} diff --git a/apps/google-docs/src/types/editModal.ts b/apps/google-docs/src/types/editModal.ts index 2408d6a732..367a3b2087 100644 --- a/apps/google-docs/src/types/editModal.ts +++ b/apps/google-docs/src/types/editModal.ts @@ -33,7 +33,7 @@ export interface EditModalNewLocation { title: string; fieldOptions: EditModalFieldOption[]; fieldMappings: EditModalFieldMapping[]; - selectedFieldIds?: string[]; + initialFieldIds: string[]; } export interface EditModalContent { @@ -43,6 +43,7 @@ export interface EditModalContent { /** Replaces the default "Selected content" heading for the preview card. */ previewSectionTitle?: string; isOpen: boolean; + isImageContent: boolean; currentLocations: EditLocationOption[]; newLocation: EditModalNewLocation; } diff --git a/apps/google-docs/test/locations/Page/components/modals/EditModal.spec.tsx b/apps/google-docs/test/locations/Page/components/modals/EditModal.spec.tsx index dd399c9968..b565e43a87 100644 --- a/apps/google-docs/test/locations/Page/components/modals/EditModal.spec.tsx +++ b/apps/google-docs/test/locations/Page/components/modals/EditModal.spec.tsx @@ -1,70 +1,40 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditModal } from '../../../../../src/locations/Page/components/review/mapping/edit-modals/EditModal'; import React from 'react'; const onClose = vi.fn(); +const onConfirmPrimary = vi.fn(); -const viewModel = { - selectedText: 'Sample selected content', - isOpen: true, - currentLocations: [ +const baseNewLocation = { + id: 'page-event-detail', + title: 'Page: Event detail', + fieldMappings: [], + fieldOptions: [ { - entryIndex: 0, - id: 'summary', - contentTypeId: 'sampleContentType', - contentTypeName: 'Sample content type', - entryName: 'Sample entry', - fieldId: 'summary', - fieldName: 'Summary', - fieldType: 'Long text', - sourceRef: { - type: 'blockText' as const, - blockId: 'mock-block-1', - start: 0, - end: 23, - flattenedRuns: [ - { - start: 0, - end: 23, - text: 'Sample selected content', - styles: {}, - }, - ], - }, - isSelected: true, + id: 'title', + fieldName: 'Title', + fieldType: 'Symbol', + fieldDisplayType: 'Short text', + isAssetField: false, }, { - entryIndex: 0, - id: 'description', - contentTypeId: 'sampleContentType', - contentTypeName: 'Sample content type', - entryName: 'Sample entry', - fieldId: 'description', - fieldName: 'Description', - fieldType: 'Short text', - sourceRef: { - type: 'blockText' as const, - blockId: 'mock-block-2', - start: 0, - end: 23, - flattenedRuns: [ - { - start: 0, - end: 23, - text: 'Sample selected content', - styles: {}, - }, - ], - }, + id: 'summary', + fieldName: 'Summary', + fieldType: 'Text', + fieldDisplayType: 'Long text', + isAssetField: false, }, ], - newLocation: { - id: '', - title: '', - fieldMappings: [], - fieldOptions: [], - }, + initialFieldIds: [], +}; + +const baseViewModel = { + selectedText: 'Sample selected content', + isOpen: true, + isImageContent: false, + currentLocations: [], + newLocation: baseNewLocation, }; describe('EditModal', () => { @@ -72,156 +42,79 @@ describe('EditModal', () => { vi.clearAllMocks(); }); - it('renders the provided title and configured labels', async () => { + it('renders the provided title and button label', async () => { render( ); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Exclude content' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Edit content mapping' })).toBeTruthy(); expect(screen.getByText('"Sample selected content"')).toBeTruthy(); - expect(screen.getAllByText('Sample content type')).toHaveLength(2); - expect(screen.getByText('Choose which location to use.')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Exclude content' })).toBeTruthy(); - expect(screen.queryByText('New location')).toBeNull(); - }); - }); - - it('allows selecting and toggling location cards', async () => { - render( - - ); - - const locationCards = screen - .getAllByRole('button') - .filter((element) => element.getAttribute('aria-pressed') !== null); - const [summaryCard, descriptionCard] = locationCards; - - expect(summaryCard).toHaveAttribute('aria-pressed', 'false'); - expect(descriptionCard).toHaveAttribute('aria-pressed', 'false'); - - fireEvent.click(summaryCard); - - await waitFor(() => { - expect(summaryCard).toHaveAttribute('aria-pressed', 'true'); - expect(descriptionCard).toHaveAttribute('aria-pressed', 'false'); - }); - - fireEvent.click(descriptionCard); - - await waitFor(() => { - expect(summaryCard).toHaveAttribute('aria-pressed', 'true'); - expect(descriptionCard).toHaveAttribute('aria-pressed', 'true'); - }); - - fireEvent.click(summaryCard); - - await waitFor(() => { - expect(summaryCard).toHaveAttribute('aria-pressed', 'false'); - expect(descriptionCard).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Apply' })).toBeTruthy(); }); }); - it('renders the new location section when provided', async () => { + it('renders the "Assign to fields" section when newLocation has an id', async () => { render( ); await waitFor(() => { - expect(screen.getByText('New location')).toBeTruthy(); - expect(screen.getByText("Page: Event detail (Don't enter NRF uncaffeinated.)")).toBeTruthy(); - expect(screen.getAllByText('Fields')).toHaveLength(1); - expect(screen.getAllByText('Select one or more')).toHaveLength(1); + expect(screen.getByText('Assign to fields')).toBeTruthy(); + expect(screen.getByText('Page: Event detail')).toBeTruthy(); }); }); - it('does not show destination validation in exclude mode and enables submit once a location is selected', async () => { + it('primary button is disabled when selectedFieldIds equals initialFieldIds (no change)', async () => { render( ); - expect( - screen.queryByText('No destination entry is available for the entry currently in view.') - ).toBeNull(); - expect(screen.getByRole('button', { name: 'Exclude content' })).toBeDisabled(); - - const locationCards = screen - .getAllByRole('button') - .filter((el) => el.getAttribute('aria-pressed') !== null); - fireEvent.click(locationCards[0]); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Exclude content' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); }); }); - it('keeps the current location heading visible when the section is empty', async () => { + it('does not render the "Assign to fields" section when newLocation has no id', async () => { render( ); await waitFor(() => { - expect(screen.getByText('Current location')).toBeTruthy(); - expect( - screen - .getAllByRole('button') - .filter((element) => element.getAttribute('aria-pressed') !== null) - ).toHaveLength(0); + expect(screen.queryByText('Assign to fields')).toBeNull(); }); }); });