diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx index 9dc16cc087..ad6b48dbf4 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DragSense.tsx @@ -4,7 +4,10 @@ import { Draggable } from "react-beautiful-dnd"; import { trashId } from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop"; import SenseCardContent from "goals/MergeDuplicates/MergeDupsStep/SenseCardContent"; -import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { + MergeTreeReference, + MergeTreeSense, +} from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { setSidebar } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; @@ -12,11 +15,9 @@ import theme from "types/theme"; interface DragSenseProps { index: number; - wordId: string; - mergeSenseId: string; - mergeSenses: MergeTreeSense[]; isOnlySenseInProtectedWord: boolean; - isProtectedSense: boolean; + mergeSenses: MergeTreeSense[]; + senseRef: MergeTreeReference; } function arraysEqual(arr1: T[], arr2: T[]): boolean { @@ -48,17 +49,13 @@ export default function DragSense(props: DragSenseProps): ReactElement { (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar ); const isInSidebar = - sidebar.wordId === props.wordId && - sidebar.mergeSenseId === props.mergeSenseId && + sidebar.senseRef.wordId === props.senseRef.wordId && + sidebar.senseRef.mergeSenseId === props.senseRef.mergeSenseId && sidebar.mergeSenses.length > 1; const updateSidebar = useCallback(() => { dispatch( - setSidebar({ - mergeSenses: props.mergeSenses, - wordId: props.wordId, - mergeSenseId: props.mergeSenseId, - }) + setSidebar({ mergeSenses: props.mergeSenses, senseRef: props.senseRef }) ); }, [dispatch, props]); @@ -91,12 +88,8 @@ export default function DragSense(props: DragSenseProps): ReactElement { return ( @@ -112,13 +105,13 @@ export default function DragSense(props: DragSenseProps): ReactElement { minWidth: 150, maxWidth: 300, opacity: - !props.isProtectedSense && + !props.senseRef.isSenseProtected && (snapshot.draggingOver === trashId || snapshot.combineWith) ? 0.7 : 1, background: isInSidebar ? "lightblue" - : props.isProtectedSense + : props.senseRef.isSenseProtected ? "lightyellow" : snapshot.draggingOver === trashId ? "red" diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index f79b4c4a28..dc6f72c526 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -76,11 +76,14 @@ export default function DropWord(props: DropWordProps): ReactElement { ); })} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx index c92b9c003a..96fda64157 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense.tsx @@ -4,10 +4,7 @@ import { Draggable } from "react-beautiful-dnd"; import { trashId } from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop"; import SenseCardContent from "goals/MergeDuplicates/MergeDupsStep/SenseCardContent"; -import { - MergeTreeReference, - MergeTreeSense, -} from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import theme from "types/theme"; @@ -21,10 +18,8 @@ export default function SidebarDragSense( props: SidebarDragSenseProps ): ReactElement { const draggableId = useAppSelector((state: StoreState) => { - const { mergeSenseId, wordId } = state.mergeDuplicateGoal.tree.sidebar; - const order = props.index; - const ref: MergeTreeReference = { wordId, mergeSenseId, order }; - return JSON.stringify(ref); + const ref = state.mergeDuplicateGoal.tree.sidebar.senseRef; + return JSON.stringify({ ...ref, order: props.index }); }); const overrideProtection = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.overrideProtection diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx index aa11fc5853..69908d2177 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDrop.tsx @@ -1,10 +1,10 @@ import { ArrowForwardIos, HelpOutline } from "@mui/icons-material"; import { Grid, IconButton, Typography } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { Droppable } from "react-beautiful-dnd"; import SidebarDragSense from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/SidebarDragSense"; -import { MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; +import { type MergeTreeSense } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { setSidebar } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; @@ -15,16 +15,14 @@ export default function SidebarDrop(): ReactElement { const sidebar = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar ); + const { mergeSenseId, wordId } = sidebar.senseRef; const vernacular = useAppSelector((state: StoreState) => { const tree = state.mergeDuplicateGoal.tree; - return tree.words[tree.sidebar.wordId]?.vern; + return tree.words[tree.sidebar.senseRef.wordId]?.vern; }); return ( - + {(providedDroppable): ReactElement => (
state.mergeDuplicateGoal.overrideProtection ); - const senses = useAppSelector( - (state: StoreState) => state.mergeDuplicateGoal.data.senses - ); const sidebarOpen = useAppSelector( (state: StoreState) => state.mergeDuplicateGoal.tree.sidebar.mergeSenses.length > 1 @@ -45,69 +46,65 @@ export default function MergeDragDrop(): ReactElement { (state: StoreState) => state.mergeDuplicateGoal.tree.words ); - const [protectedDataText, setProtectedDataText] = useState(""); - const [protectedDest, setProtectedDest] = useState< - MergeTreeReference | undefined - >(); - const [protectedSrc, setProtectedSrc] = useState< - MergeTreeReference | undefined - >(); + interface ProtectedOverride { + combinePayload?: CombineSenseMergePayload; + deletePayload?: MergeTreeReference; + movePayload?: MoveSensePayload; + orderPayload?: OrderSensePayload; + protectReason: string; + } + const [override, setOverride] = useState(); const [srcToDelete, setSrcToDelete] = useState< MergeTreeReference | undefined >(); const { t } = useTranslation(); - function startOverrideProtectedData( - protectedData: string, - src: MergeTreeReference, - dest?: MergeTreeReference - ): void { - setProtectedDest(dest); - setProtectedSrc(src); - setProtectedDataText( - t("mergeDups.helpText.protectedOverrideWarning", { - val: protectedData, - }) - ); - } - function handleDrop(res: DropResult): void { const src: MergeTreeReference = JSON.parse(res.draggableId); const srcWordId = res.source.droppableId; const srcWord = words[srcWordId]; - const senseReasons = senses[src.mergeSenseId].sense.protectReasons ?? []; - let wordReasons: ProtectReason[] = []; - if (srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1) { + + // Generate text for protected data that will be lost if user overrides. + const isOnlySenseInProtectedWord = + srcWord?.protected && Object.keys(srcWord.sensesGuids).length === 1; + let protectReason = ""; + if (overrideProtection) { + const wordReasons = isOnlySenseInProtectedWord + ? (srcWord.protectReasons ?? []) + : undefined; + const senseReasons = src.protectReasons; + protectReason = t("mergeDups.helpText.protectedOverrideWarning", { + val: protectReasonsText(t, wordReasons, senseReasons, false), + }); + } + + if (isOnlySenseInProtectedWord && !overrideProtection) { // Case 0: The final sense of a protected word cannot be moved. - if (overrideProtection) { - wordReasons = srcWord.protectReasons ?? []; - } else { - return; - } + return; } - const reasonsText = protectReasonsText(t, wordReasons, senseReasons); if (res.destination?.droppableId === trashId) { // Case 1: The sense was dropped on the trash icon. - if (src.isSenseProtected) { + if (src.isSenseProtected || isOnlySenseInProtectedWord) { // Case 1a: Cannot delete a protected sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData(reasonsText, src); + setOverride({ deletePayload: src, protectReason }); } return; } setSrcToDelete(src); } else if (res.combine) { - const combineRef: MergeTreeReference = JSON.parse( - res.combine.draggableId - ); + const combinePayload: CombineSenseMergePayload = { + dest: JSON.parse(res.combine.draggableId), + src, + }; // Case 2: the sense was dropped on another sense. - if (src.isSenseProtected) { + if (src.isSenseProtected || isOnlySenseInProtectedWord) { // Case 2a: Cannot merge a protected sense into another sense. if (overrideProtection) { // ... unless protection override is active and user confirms. - startOverrideProtectedData(reasonsText, src, combineRef); + setOverride({ combinePayload, protectReason }); } else if (srcWordId !== res.combine.droppableId) { // Otherwise, if target sense is in different word, move instead of combine. dispatch( @@ -120,13 +117,14 @@ export default function MergeDragDrop(): ReactElement { } return; } - if (combineRef.order !== undefined) { + if (combinePayload.dest.order !== undefined) { // Case 2b: If the target is a sidebar sub-sense, it cannot receive a combine. return; } // TODO: handle override case when sense is last in protected word - dispatch(combineSense({ src, dest: combineRef })); + dispatch(combineSense(combinePayload)); } else if (res.destination) { + const destOrder = res.destination.index; const destWordId = res.destination.droppableId; // Case 3: The sense was dropped in a droppable. if (srcWordId !== destWordId) { @@ -136,21 +134,26 @@ export default function MergeDragDrop(): ReactElement { return; } // Move the sense to the dest MergeWord. - dispatch( - moveSense({ src, destWordId, destOrder: res.destination.index }) - ); + const movePayload: MoveSensePayload = { destOrder, destWordId, src }; + if (isOnlySenseInProtectedWord) { + setOverride({ movePayload, protectReason }); + return; + } + dispatch(moveSense(movePayload)); } else { // Case 3b: The source & dest droppables are the same, so we reorder, not move. - const destOrder = res.destination.index; + const orderPayload: OrderSensePayload = { destOrder, src }; if ( src.order === destOrder || (destOrder === 0 && src.order !== undefined && sidebarProtected) ) { // If the sense wasn't moved or was moved within the sidebar above a protected sense, do nothing. - // TODO: Handle override case when moving above protected sense in sidebar + if (overrideProtection) { + setOverride({ orderPayload, protectReason }); + } return; } - dispatch(orderSense({ src, destOrder })); + dispatch(orderSense(orderPayload)); } } } @@ -163,16 +166,16 @@ export default function MergeDragDrop(): ReactElement { } function onConfirmOverride(): void { - if (protectedSrc) { - if (protectedDest) { - dispatch(combineSense({ src: protectedSrc, dest: protectedDest })); - } else { - dispatch(deleteSense(protectedSrc)); - } - setProtectedSrc(undefined); + if (override?.combinePayload) { + dispatch(combineSense(override.combinePayload)); + } else if (override?.deletePayload) { + dispatch(deleteSense(override.deletePayload)); + } else if (override?.movePayload) { + dispatch(moveSense(override.movePayload)); + } else if (override?.orderPayload) { + dispatch(orderSense(override.orderPayload)); } - setProtectedDest(undefined); - setProtectedDataText(""); + setOverride(undefined); } function renderSidebar(): ReactElement { @@ -235,9 +238,9 @@ export default function MergeDragDrop(): ReactElement { {renderSidebar()} setProtectedDataText("")} + open={!!override} + text={override?.protectReason ?? ""} + handleCancel={() => setOverride(undefined)} handleConfirm={onConfirmOverride} /> , - wordReasons: ProtectReason[], - senseReasons: ProtectReason[] + wordReasons: ProtectReason[] = [], + senseReasons: ProtectReason[] = [], + defaultPreface = true ): string { const wordTexts = wordReasons.map((r) => wordReasonText(t, r)); const senseTexts = senseReasons.map((r) => senseReasonText(t, r)); - return t("mergeDups.helpText.protectedData", { - val: [...wordTexts, ...senseTexts].join(sep), - }); + const val = [...wordTexts, ...senseTexts].join(sep); + return defaultPreface ? t("mergeDups.helpText.protectedData", { val }) : val; } /** Cases match Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) */ diff --git a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts index 38b06022ae..85b544386b 100644 --- a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts @@ -14,6 +14,7 @@ import { newFlag, newNote, newSense } from "types/word"; export interface MergeTreeSense { order: number; protected: boolean; + protectReasons?: ProtectReason[]; srcWordId: string; sense: Sense; } @@ -30,6 +31,7 @@ export interface MergeTreeReference { mergeSenseId: string; order?: number; isSenseProtected?: boolean; + protectReasons?: ProtectReason[]; } export interface MergeTreeWord { @@ -78,6 +80,7 @@ export function convertSenseToMergeTreeSense( return { order, protected: sense?.accessibility === Status.Protected, + protectReasons: sense?.protectReasons ?? undefined, srcWordId, sense, }; @@ -98,14 +101,15 @@ export function convertWordToMergeTreeWord(word: Word): MergeTreeWord { export interface Sidebar { mergeSenses: MergeTreeSense[]; - wordId: string; - mergeSenseId: string; + senseRef: MergeTreeReference; } export const defaultSidebar: Sidebar = { mergeSenses: [], - wordId: "", - mergeSenseId: "", + senseRef: { + wordId: "", + mergeSenseId: "", + }, }; export interface MergeTree { diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index 48a4ac439c..30ae33bc16 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -91,7 +91,7 @@ const mergeDuplicatesSlice = createSlice({ } // If the deleted sense was open in the sidebar, reset the sidebar. - const { mergeSenseId, wordId } = state.tree.sidebar; + const { mergeSenseId, wordId } = state.tree.sidebar.senseRef; if (mergeSenseId === srcRef.mergeSenseId && wordId === srcRef.wordId) { state.tree.sidebar = defaultSidebar; } diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx index d0059017cc..d707da1500 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx @@ -102,8 +102,11 @@ describe("MergeDupsReducer", () => { ...defaultTree, sidebar: { ...defaultSidebar, - mergeSenseId: "word2_senseA", - wordId: "word2", + senseRef: { + ...defaultSidebar.senseRef, + mergeSenseId: "word2_senseA", + wordId: "word2", + }, }, words: testTreeWords(), }, @@ -116,7 +119,7 @@ describe("MergeDupsReducer", () => { sidebarClosed = false ): void { const { sidebar, words } = mergeDupStepReducer(mockState, action).tree; - expect(!sidebar.wordId).toEqual(sidebarClosed); + expect(!sidebar.senseRef.wordId).toEqual(sidebarClosed); // Stringify for this test, because order within `.sensesGuids` matters. expect(JSON.stringify(words)).toEqual(JSON.stringify(expectedWords)); }