Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 80 additions & 36 deletions src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ 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,85 @@ 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>

<LoadingButton
buttonProps={{
color: "secondary",
onClick: revert,
title: t("mergeDups.helpText.revertSet"),
id: "merge-revert",
disabled: !stateHasChanged,
}}
>
{t("buttons.revertSet")}
</LoadingButton>

{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,141 @@
import "@testing-library/jest-dom";
import { act, 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 { OffOnSetting } from "api/models";
import SaveDeferButtons from "goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons";
import { defaultState as defaultMergeDupState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes";
import { StoreState } from "rootRedux/types";
import { testWordList } from "types/word";

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

const mockStore = configureMockStore<StoreState>();

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 = { words: {}, sidebar: {}, deletedSenseGuids: [] };
const audio = { counts: {}, moves: hasChanges ? { [words[0].id]: [] } : {} };

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

return mockStore({
mergeDuplicateGoal,
currentProjectState: {
project: {
protectedDataOverrideEnabled: OffOnSetting.Off,
},
},
} as any);
}

describe("SaveDeferButtons", () => {
it("renders all buttons", () => {
render(
<Provider store={setMockStore()}>
<SaveDeferButtons />
</Provider>
);

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", () => {
render(
<Provider store={setMockStore(false)}>
<SaveDeferButtons />
</Provider>
);

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

it("enables revert button when changes exist", () => {
render(
<Provider store={setMockStore(true)}>
<SaveDeferButtons />
</Provider>
);

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

it("shows confirmation dialog when revert is clicked", async () => {
render(
<Provider store={setMockStore(true)}>
<SaveDeferButtons />
</Provider>
);

const revertButton = screen.getByTitle("mergeDups.helpText.revertSet");
await act(async () => {
await userEvent.click(revertButton);
});

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

it("cancels revert when cancel button is clicked", async () => {
render(
<Provider store={setMockStore(true)}>
<SaveDeferButtons />
</Provider>
);

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 () => {
const store = setMockStore(true);
render(
<Provider store={store}>
<SaveDeferButtons />
</Provider>
);

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" })
);
});
});
});
25 changes: 25 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,25 @@ export function mergeAll() {
};
}

// Helper function to check if the current state has changed from initial
export function hasStateChanged(state: MergeTreeState): boolean {
if (!state.initialState) {
return false;
}

// Compare current tree and audio.moves with initial state
const currentStateJson = JSON.stringify({
tree: state.tree,
audioMoves: state.audio.moves,
});
const initialStateJson = JSON.stringify({
tree: state.initialState.tree,
audioMoves: {},
});

return currentStateJson !== initialStateJson;
}

// Used in MergeDups cases of GoalActions functions

export function dispatchMergeStepData(goal: MergeDups | ReviewDeferredDups) {
Expand Down
Loading