Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down Expand Up @@ -543,6 +545,7 @@
"invite": "Invite",
"proceedWithCaution": "Proceed with Caution!",
"reject": "Reject",
"revertSet": "Revert set",
"rejected": "Rejected",
"reset": "Reset",
"restore": "Restore",
Expand Down
115 changes: 78 additions & 37 deletions src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();

Expand All @@ -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 (
<Grid2 container spacing={3}>
<LoadingButton
loading={isSaving}
buttonProps={{
onClick: saveContinue,
title: t("mergeDups.helpText.saveAndContinue"),
id: "merge-save",
}}
>
{t("buttons.saveAndContinue")}
</LoadingButton>

<LoadingButton
loading={isDeferring}
buttonProps={{
color: "secondary",
onClick: defer,
title: t("mergeDups.helpText.defer"),
id: "merge-defer",
}}
>
{t("buttons.defer")}
</LoadingButton>

{hasProtected && (
<FormControlLabel
control={
<Checkbox
checked={overrideProtection}
onChange={() => dispatch(toggleOverrideProtection())}
/>
}
label={t("mergeDups.helpText.protectedOverride")}
/>
)}
</Grid2>
<>
<Grid2 container spacing={3}>
<LoadingButton
loading={isSaving}
buttonProps={{
onClick: saveContinue,
title: t("mergeDups.helpText.saveAndContinue"),
id: "merge-save",
}}
>
{t("buttons.saveAndContinue")}
</LoadingButton>

<LoadingButton
loading={isDeferring}
buttonProps={{
color: "secondary",
onClick: defer,
title: t("mergeDups.helpText.defer"),
id: "merge-defer",
}}
>
{t("buttons.defer")}
</LoadingButton>

<Button
color="secondary"
disabled={!stateHasChanged}
id="merge-revert"
onClick={revert}
title={t("mergeDups.helpText.revertSet")}
variant="contained"
>
{t("buttons.revertSet")}
</Button>

{hasProtected && (
<FormControlLabel
control={
<Checkbox
checked={overrideProtection}
onChange={() => dispatch(toggleOverrideProtection())}
/>
}
label={t("mergeDups.helpText.protectedOverride")}
/>
)}
</Grid2>

<CancelConfirmDialog
handleCancel={cancelRevert}
handleConfirm={confirmRevert}
open={showRevertDialog}
text="mergeDups.helpText.revertSetDialog"
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -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<void> => {
render(
<Provider store={createMockStore(hasChanges)}>
<SaveDeferButtons />
</Provider>
);
};

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());
});
});
21 changes: 21 additions & 0 deletions src/goals/MergeDuplicates/Redux/MergeDupsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
moveSenseAction,
orderDuplicateAction,
orderSenseAction,
resetTreeToInitialAction,
setDataAction,
setSidebarAction,
setVernacularAction,
Expand All @@ -31,6 +32,7 @@ import {
import {
CombineSenseMergePayload,
FlagWordPayload,
MergeTreeState,
MoveSensePayload,
OrderSensePayload,
SetVernacularPayload,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
},

Expand All @@ -347,6 +356,7 @@ export const {
moveSenseAction,
orderDuplicateAction,
orderSenseAction,
resetTreeToInitialAction,
setDataAction,
setSidebarAction,
setVernacularAction,
Expand Down
1 change: 1 addition & 0 deletions src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface MergeTreeState {
hasProtected: boolean;
mergeWords: MergeWords[];
overrideProtection: boolean;
initialTree?: string;
}

export const defaultState: MergeTreeState = {
Expand Down
35 changes: 35 additions & 0 deletions src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
deleteSense,
flagWord,
getMergeWords,
hasStateChanged,
moveSense,
orderSense,
resetTreeToInitial,
setData,
toggleOverrideProtection,
} from "goals/MergeDuplicates/Redux/MergeDupsActions";
Expand Down Expand Up @@ -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<MergeTreeWord> {
return {
word1: newMergeTreeWord("senses:A0", {
Expand Down
Loading