diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 4cb290d594..909612a9db 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -458,6 +458,8 @@
"dragCard": "Drag a card here to merge",
"saveAndContinue": "Save changes and load a new set of words",
"defer": "Discard changes and load a new set of words",
+ "revertSet": "Revert all changes made to this set of words",
+ "revertSetDialog": "Are you sure you want to discard all changes and start over with this set of words?",
"noDups": "Nothing to merge.",
"delete": "Delete sense",
"deleteDialog": "Delete this sense?",
@@ -543,6 +545,7 @@
"invite": "Invite",
"proceedWithCaution": "Proceed with Caution!",
"reject": "Reject",
+ "revertSet": "Revert set",
"rejected": "Rejected",
"reset": "Reset",
"restore": "Restore",
diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx
index 6088f382b9..50948543a4 100644
--- a/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx
+++ b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx
@@ -1,12 +1,15 @@
-import { Checkbox, FormControlLabel, Grid2 } from "@mui/material";
+import { Button, Checkbox, FormControlLabel, Grid2 } from "@mui/material";
import { ReactElement, useState } from "react";
import { useTranslation } from "react-i18next";
import { OffOnSetting } from "api/models";
import LoadingButton from "components/Buttons/LoadingButton";
+import CancelConfirmDialog from "components/Dialogs/CancelConfirmDialog";
import {
deferMerge,
+ hasStateChanged,
mergeAll,
+ resetTreeToInitial,
setSidebar,
toggleOverrideProtection,
} from "goals/MergeDuplicates/Redux/MergeDupsActions";
@@ -26,9 +29,13 @@ export default function SaveDeferButtons(): ReactElement {
const overrideProtection = useAppSelector(
(state: StoreState) => state.mergeDuplicateGoal.overrideProtection
);
+ const stateHasChanged = useAppSelector((state: StoreState) =>
+ hasStateChanged(state.mergeDuplicateGoal)
+ );
const [isDeferring, setIsDeferring] = useState(false);
const [isSaving, setIsSaving] = useState(false);
+ const [showRevertDialog, setShowRevertDialog] = useState(false);
const { t } = useTranslation();
@@ -50,42 +57,76 @@ export default function SaveDeferButtons(): ReactElement {
await dispatch(mergeAll()).then(next);
};
+ const revert = (): void => {
+ setShowRevertDialog(true);
+ };
+
+ const cancelRevert = (): void => {
+ setShowRevertDialog(false);
+ };
+
+ const confirmRevert = (): void => {
+ dispatch(setSidebar());
+ dispatch(resetTreeToInitial());
+ setShowRevertDialog(false);
+ };
+
return (
-
-
- {t("buttons.saveAndContinue")}
-
-
-
- {t("buttons.defer")}
-
-
- {hasProtected && (
- dispatch(toggleOverrideProtection())}
- />
- }
- label={t("mergeDups.helpText.protectedOverride")}
- />
- )}
-
+ <>
+
+
+ {t("buttons.saveAndContinue")}
+
+
+
+ {t("buttons.defer")}
+
+
+
+
+ {hasProtected && (
+ dispatch(toggleOverrideProtection())}
+ />
+ }
+ label={t("mergeDups.helpText.protectedOverride")}
+ />
+ )}
+
+
+
+ >
);
}
diff --git a/src/goals/MergeDuplicates/MergeDupsStep/tests/SaveDeferButtons.test.tsx b/src/goals/MergeDuplicates/MergeDupsStep/tests/SaveDeferButtons.test.tsx
new file mode 100644
index 0000000000..c804a9ac8c
--- /dev/null
+++ b/src/goals/MergeDuplicates/MergeDupsStep/tests/SaveDeferButtons.test.tsx
@@ -0,0 +1,94 @@
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Provider } from "react-redux";
+import configureMockStore from "redux-mock-store";
+
+import SaveDeferButtons from "goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons";
+import { resetTreeToInitial } from "goals/MergeDuplicates/Redux/MergeDupsActions";
+import { MergeTreeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes";
+import { defaultState } from "rootRedux/types";
+
+jest.mock("backend");
+jest.mock("goals/Redux/GoalActions");
+jest.mock("rootRedux/hooks", () => ({
+ ...jest.requireActual("rootRedux/hooks"),
+ useAppDispatch: () => mockDispatch,
+}));
+
+const mockDispatch = jest.fn();
+const mockStore = configureMockStore();
+
+function createMockStore(hasChanges = false): any {
+ const { audio, tree } = defaultState.mergeDuplicateGoal;
+
+ const mergeDuplicateGoal: MergeTreeState = {
+ ...defaultState.mergeDuplicateGoal,
+ audio: hasChanges ? { ...audio, moves: { a: ["b"] } } : audio,
+ initialTree: JSON.stringify(tree),
+ };
+
+ return mockStore({ ...defaultState, mergeDuplicateGoal });
+}
+
+const renderSaveDeferButtons = async (hasChanges: boolean): Promise => {
+ render(
+
+
+
+ );
+};
+
+describe("SaveDeferButtons", () => {
+ it("renders all buttons", async () => {
+ await renderSaveDeferButtons(false);
+
+ expect(screen.getByText("buttons.saveAndContinue")).toBeInTheDocument();
+ expect(screen.getByText("buttons.defer")).toBeInTheDocument();
+ expect(screen.getByText("buttons.revertSet")).toBeInTheDocument();
+ });
+
+ it("disables revert button when no changes", async () => {
+ await renderSaveDeferButtons(false);
+
+ expect(screen.getByText("buttons.revertSet")).toBeDisabled();
+ });
+
+ it("enables revert button when changes exist", async () => {
+ await renderSaveDeferButtons(true);
+
+ expect(screen.getByText("buttons.revertSet")).toBeEnabled();
+ });
+
+ it("shows confirmation dialog when revert is clicked", async () => {
+ await renderSaveDeferButtons(true);
+ await userEvent.click(screen.getByText("buttons.revertSet"));
+
+ expect(
+ screen.getByText("mergeDups.helpText.revertSetDialog")
+ ).toBeInTheDocument();
+ });
+
+ it("cancels revert when cancel button is clicked", async () => {
+ await renderSaveDeferButtons(true);
+ await userEvent.click(screen.getByText("buttons.revertSet"));
+
+ // Dialog should be visible
+ expect(screen.getByRole("dialog")).toBeVisible();
+
+ await userEvent.click(screen.getByText("buttons.cancel"));
+
+ // After clicking cancel, wait for the dialog to close and be removed from DOM
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+
+ it("dispatches reset action when confirm is clicked", async () => {
+ await renderSaveDeferButtons(true);
+ await userEvent.click(screen.getByText("buttons.revertSet"));
+ await userEvent.click(screen.getByText("buttons.confirm"));
+
+ expect(mockDispatch).toHaveBeenCalledWith(resetTreeToInitial());
+ });
+});
diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts
index 4b0a762d7b..cfd35b2ea2 100644
--- a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts
+++ b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts
@@ -23,6 +23,7 @@ import {
moveSenseAction,
orderDuplicateAction,
orderSenseAction,
+ resetTreeToInitialAction,
setDataAction,
setSidebarAction,
setVernacularAction,
@@ -31,6 +32,7 @@ import {
import {
CombineSenseMergePayload,
FlagWordPayload,
+ MergeTreeState,
MoveSensePayload,
OrderSensePayload,
SetVernacularPayload,
@@ -83,6 +85,10 @@ export function orderSense(payload: OrderSensePayload): PayloadAction {
}
}
+export function resetTreeToInitial(): Action {
+ return resetTreeToInitialAction();
+}
+
export function setSidebar(sidebar?: Sidebar): PayloadAction {
return setSidebarAction(sidebar ?? defaultSidebar);
}
@@ -153,6 +159,21 @@ export function mergeAll() {
};
}
+/** Helper function to check if the current state has changed from initial */
+export function hasStateChanged(state: MergeTreeState): boolean {
+ if (!state.initialTree) {
+ return false;
+ }
+
+ // Check if audio.moves has any entries
+ if (Object.keys(state.audio.moves).length > 0) {
+ return true;
+ }
+
+ // Compare current tree with initial state
+ return JSON.stringify(state.tree) !== state.initialTree;
+}
+
// Used in MergeDups cases of GoalActions functions
export function dispatchMergeStepData(goal: MergeDups | ReviewDeferredDups) {
diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts
index dfea6a1a49..0494c6a5c8 100644
--- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts
+++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts
@@ -290,6 +290,13 @@ const mergeDuplicatesSlice = createSlice({
}
},
+ resetTreeToInitialAction: (state) => {
+ if (state.initialTree) {
+ state.tree = JSON.parse(state.initialTree);
+ state.audio.moves = {};
+ }
+ },
+
setSidebarAction: (state, action) => {
const sidebar: Sidebar = action.payload;
// Only open sidebar with multiple senses.
@@ -321,6 +328,8 @@ const mergeDuplicatesSlice = createSlice({
state.audio = { ...defaultAudio, counts };
state.mergeWords = [];
state.overrideProtection = false;
+ // Store the initial tree state for reset functionality
+ state.initialTree = JSON.stringify(state.tree);
}
},
@@ -347,6 +356,7 @@ export const {
moveSenseAction,
orderDuplicateAction,
orderSenseAction,
+ resetTreeToInitialAction,
setDataAction,
setSidebarAction,
setVernacularAction,
diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts
index 271faf7b53..cd05708964 100644
--- a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts
+++ b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts
@@ -36,6 +36,7 @@ export interface MergeTreeState {
hasProtected: boolean;
mergeWords: MergeWords[];
overrideProtection: boolean;
+ initialTree?: string;
}
export const defaultState: MergeTreeState = {
diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.ts b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.ts
index bf26086d09..ab3f0555c4 100644
--- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.ts
+++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.ts
@@ -20,8 +20,10 @@ import {
deleteSense,
flagWord,
getMergeWords,
+ hasStateChanged,
moveSense,
orderSense,
+ resetTreeToInitial,
setData,
toggleOverrideProtection,
} from "goals/MergeDuplicates/Redux/MergeDupsActions";
@@ -86,6 +88,39 @@ describe("MergeDupsReducer", () => {
);
});
+ test("resetTreeToInitial restores initial state", () => {
+ const store = setupStore();
+ const words = testWordList();
+
+ // Set initial data
+ store.dispatch(setData(words));
+ const initialTree = JSON.stringify(
+ store.getState().mergeDuplicateGoal.tree
+ );
+
+ // Make a simple change - flag a word
+ const wordWithSenses = words.find((w) => w.senses.length > 0);
+ if (!wordWithSenses) {
+ throw new Error("Test requires a word with senses");
+ }
+ store.dispatch(
+ flagWord({ wordId: wordWithSenses.id, flag: newFlag("test") })
+ );
+
+ // Verify state has changed
+ const changedState = store.getState().mergeDuplicateGoal;
+ expect(JSON.stringify(changedState.tree)).not.toEqual(initialTree);
+ expect(hasStateChanged(changedState)).toBe(true);
+
+ // Reset to initial
+ store.dispatch(resetTreeToInitial());
+
+ // Verify tree is restored
+ const restoredState = store.getState().mergeDuplicateGoal;
+ expect(JSON.stringify(restoredState.tree)).toEqual(initialTree);
+ expect(restoredState.audio.moves).toEqual({});
+ });
+
function testTreeWords(): Hash {
return {
word1: newMergeTreeWord("senses:A0", {