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", {