Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
116 changes: 79 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 @@ -44,48 +51,83 @@ export default function SaveDeferButtons(): ReactElement {
await dispatch(deferMerge()).then(next);
};

const revert = (): void => {
setShowRevertDialog(true);
};

const confirmRevert = (): void => {
dispatch(setSidebar());
dispatch(resetTreeToInitial());
setShowRevertDialog(false);
};

const cancelRevert = (): void => {
setShowRevertDialog(false);
};

const saveContinue = async (): Promise<void> => {
setIsSaving(true);
dispatch(setSidebar());
await dispatch(mergeAll()).then(next);
};

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"
onClick={revert}
title={t("mergeDups.helpText.revertSet")}
id="merge-revert"
disabled={!stateHasChanged}
>
{t("buttons.revertSet")}
</Button>

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

<CancelConfirmDialog
open={showRevertDialog}
text="mergeDups.helpText.revertSetDialog"
handleCancel={cancelRevert}
handleConfirm={confirmRevert}
buttonIdCancel="revert-cancel"
buttonIdConfirm="revert-confirm"
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 { defaultState as defaultMergeDupState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes";
import { defaultState } from "rootRedux/types";
import { testWordList } from "types/word";

jest.mock("goals/Redux/GoalActions");
jest.mock("backend");

const mockStore = configureMockStore();

let store: any;

function setMockStore(hasChanges = false): any {
const words = testWordList();
const data = { words: { [words[0].id]: words[0] }, senses: {} };
const tree = hasChanges
? {
words: {
[words[0].id]: { sensesGuids: {}, vern: "test", flag: {} },
},
sidebar: {},
deletedSenseGuids: [],
}
: { words: {}, sidebar: {}, deletedSenseGuids: [] };
const initialTree = JSON.stringify({
words: {},
sidebar: {},
deletedSenseGuids: [],
});
const audio = {
counts: {},
moves: hasChanges ? { [words[0].id]: [] } : {},
};

const mergeDuplicateGoal = {
...defaultMergeDupState,
data,
tree,
audio,
initialTree,
};

store = mockStore({
...defaultState,
mergeDuplicateGoal,
});
return store;
}

const renderSaveDeferButtons = async (hasChanges: boolean): Promise<void> => {
render(
<Provider store={setMockStore(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);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
expect(revertButton).toBeDisabled();
});

it("enables revert button when changes exist", async () => {
await renderSaveDeferButtons(true);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
expect(revertButton).not.toBeDisabled();
});

it("shows confirmation dialog when revert is clicked", async () => {
await renderSaveDeferButtons(true);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
await userEvent.click(revertButton);

expect(
screen.getByText("mergeDups.helpText.revertSetDialog")
).toBeInTheDocument();
});

it("cancels revert when cancel button is clicked", async () => {
await renderSaveDeferButtons(true);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
await userEvent.click(revertButton);

// Dialog should be visible
expect(screen.getByRole("dialog")).toBeVisible();

const cancelButton = screen.getByTestId("revert-cancel");
await userEvent.click(cancelButton);

// 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);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
await userEvent.click(revertButton);

const confirmButton = screen.getByTestId("revert-confirm");
await userEvent.click(confirmButton);

// Wait for the action to be dispatched
await waitFor(() => {
const actions = store.getActions();
expect(actions).toContainEqual(
expect.objectContaining({
type: "mergeDupStepReducer/resetTreeToInitialAction",
})
);
});
});
});
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
Loading
Loading