Skip to content

Commit

Permalink
[DataEntry > NewEntry] Prevent double submission (#2946)
Browse files Browse the repository at this point in the history
  • Loading branch information
imnasnainaec authored Feb 14, 2024
1 parent 8677250 commit 6ea0515
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 40 deletions.
25 changes: 19 additions & 6 deletions src/components/DataEntry/DataEntryTable/NewEntry/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ import { StoreState } from "types";
import theme from "types/theme";
import { FileWithSpeakerId } from "types/word";

const idAffix = "new-entry";
export enum NewEntryId {
ButtonDelete = "new-entry-delete-button",
ButtonNote = "new-entry-note-button",
GridNewEntry = "new-entry",
TextFieldGloss = "new-entry-gloss-textfield",
TextFieldVern = "new-entry-vernacular-textfield",
}

export enum FocusTarget {
Gloss,
Expand Down Expand Up @@ -103,6 +109,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {

const [senseOpen, setSenseOpen] = useState(false);
const [shouldFocus, setShouldFocus] = useState<FocusTarget | undefined>();
const [submitting, setSubmitting] = useState(false);
const [vernOpen, setVernOpen] = useState(false);
const [wasTreeClosed, setWasTreeClosed] = useState(false);

Expand All @@ -124,6 +131,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {

const resetState = useCallback((): void => {
resetNewEntry();
setSubmitting(false);
setVernOpen(false);
focus(FocusTarget.Vernacular);
}, [focus, resetNewEntry]);
Expand Down Expand Up @@ -169,6 +177,11 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
};

const addNewEntryAndReset = async (): Promise<void> => {
// Prevent double-submission
if (submitting) {
return;
}
setSubmitting(true);
await addNewEntry();
resetState();
};
Expand Down Expand Up @@ -228,7 +241,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
};

return (
<Grid container id={idAffix} alignItems="center">
<Grid alignItems="center" container id={NewEntryId.GridNewEntry}>
<Grid container item xs={4} style={gridItemStyle(2)}>
<Grid item xs={12}>
<VernWithSuggestions
Expand All @@ -252,7 +265,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
// If enter pressed from the vern field, check whether gloss is empty
handleEnter={() => handleEnter(true)}
vernacularLang={vernacularLang}
textFieldId={`${idAffix}-vernacular`}
textFieldId={NewEntryId.TextFieldVern}
onUpdate={() => conditionalFocus(FocusTarget.Vernacular)}
/>
<VernDialog
Expand Down Expand Up @@ -281,17 +294,17 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
// If enter pressed from the gloss field, don't check whether gloss is empty
handleEnter={() => handleEnter(false)}
analysisLang={analysisLang}
textFieldId={`${idAffix}-gloss`}
textFieldId={NewEntryId.TextFieldGloss}
onUpdate={() => conditionalFocus(FocusTarget.Gloss)}
/>
</Grid>
<Grid item xs={1} style={gridItemStyle(1)}>
{!selectedDup?.id && (
// note is not available if user selected to modify an exiting entry
<EntryNote
buttonId={NewEntryId.ButtonNote}
noteText={newNote}
updateNote={setNewNote}
buttonId="note-entry-new"
/>
)}
</Grid>
Expand All @@ -306,8 +319,8 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
</Grid>
<Grid item xs={1} style={gridItemStyle(1)}>
<DeleteEntry
buttonId={NewEntryId.ButtonDelete}
removeEntry={() => resetState()}
buttonId={`${idAffix}-delete`}
/>
</Grid>
<EnterGrid />
Expand Down
202 changes: 168 additions & 34 deletions src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,184 @@
import { createRef } from "react";
import { type ReactElement, createRef } from "react";
import { Provider } from "react-redux";
import renderer from "react-test-renderer";
import { type ReactTestRenderer, act, create } from "react-test-renderer";
import configureMockStore from "redux-mock-store";

import "tests/reactI18nextMock";

import NewEntry from "components/DataEntry/DataEntryTable/NewEntry";
import {
GlossWithSuggestions,
VernWithSuggestions,
} from "components/DataEntry/DataEntryTable/EntryCellComponents";
import NewEntry, {
NewEntryId,
} from "components/DataEntry/DataEntryTable/NewEntry";
import { newWritingSystem } from "types/writingSystem";

jest.mock("@mui/material/Autocomplete", () => "div");
jest.mock(
"@mui/material/Autocomplete",
() => (props: any) => mockAutocomplete(props)
);

jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div");

/** Bypass the Autocomplete and render its internal input with the props of both. */
const mockAutocomplete = (props: {
renderInput: (params: any) => ReactElement;
}): ReactElement => {
const { renderInput, ...params } = props;
return renderInput(params);
};

const mockAddNewAudio = jest.fn();
const mockAddNewEntry = jest.fn();
const mockDelNewAudio = jest.fn();
const mockSetNewGloss = jest.fn();
const mockSetNewNote = jest.fn();
const mockSetNewVern = jest.fn();
const mockSetSelectedDup = jest.fn();
const mockSetSelectedSense = jest.fn();
const mockRepNewAudio = jest.fn();
const mockResetNewEntry = jest.fn();
const mockUpdateWordWithNewGloss = jest.fn();

const mockStore = configureMockStore()({ treeViewState: { open: false } });

let renderer: ReactTestRenderer;

const renderNewEntry = async (
vern = "",
gloss = "",
note = ""
): Promise<void> => {
await act(async () => {
renderer = create(
<Provider store={mockStore}>
<NewEntry
analysisLang={newWritingSystem()}
vernacularLang={newWritingSystem()}
// Parent component handles new entry state:
addNewEntry={mockAddNewEntry}
resetNewEntry={mockResetNewEntry}
updateWordWithNewGloss={mockUpdateWordWithNewGloss}
newAudio={[]}
addNewAudio={mockAddNewAudio}
delNewAudio={mockDelNewAudio}
repNewAudio={mockRepNewAudio}
newGloss={gloss}
setNewGloss={mockSetNewGloss}
newNote={note}
setNewNote={mockSetNewNote}
newVern={vern}
setNewVern={mockSetNewVern}
vernInput={createRef<HTMLInputElement>()}
// Parent component handles vern suggestion state:
setSelectedDup={mockSetSelectedDup}
setSelectedSense={mockSetSelectedSense}
suggestedVerns={[]}
suggestedDups={[]}
/>
</Provider>
);
});
};

beforeEach(() => {
jest.resetAllMocks();
});

describe("NewEntry", () => {
it("renders without crashing", () => {
renderer.act(() => {
renderer.create(
<Provider store={mockStore}>
<NewEntry
analysisLang={newWritingSystem()}
vernacularLang={newWritingSystem()}
// Parent component handles new entry state:
addNewEntry={jest.fn()}
resetNewEntry={jest.fn()}
updateWordWithNewGloss={jest.fn()}
newAudio={[]}
addNewAudio={jest.fn()}
delNewAudio={jest.fn()}
repNewAudio={jest.fn()}
newGloss={""}
setNewGloss={jest.fn()}
newNote={""}
setNewNote={jest.fn()}
newVern={""}
setNewVern={jest.fn()}
vernInput={createRef<HTMLInputElement>()}
// Parent component handles vern suggestion state:
setSelectedDup={jest.fn()}
setSelectedSense={jest.fn()}
suggestedVerns={[]}
suggestedDups={[]}
/>
</Provider>
);
it("does not submit without a vernacular", async () => {
await renderNewEntry("", "gloss");
await act(async () => {
renderer.root.findByType(GlossWithSuggestions).props.handleEnter();
});
expect(mockAddNewEntry).not.toHaveBeenCalled();
});

it("does not submit with vernacular Enter if gloss is empty", async () => {
await renderNewEntry("vern", "");
await act(async () => {
renderer.root.findByType(VernWithSuggestions).props.handleEnter();
});
expect(mockAddNewEntry).not.toHaveBeenCalled();
});

it("does submit with gloss Enter if gloss is empty", async () => {
await renderNewEntry("vern", "");
await act(async () => {
renderer.root.findByType(GlossWithSuggestions).props.handleEnter();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
});

it("resets when the delete button is clicked", async () => {
await renderNewEntry();
expect(mockResetNewEntry).not.toHaveBeenCalled();
await act(async () => {
renderer.root
.findByProps({ id: NewEntryId.ButtonDelete })
.props.onClick();
});
expect(mockResetNewEntry).toHaveBeenCalledTimes(1);
});

it("resets new entry after awaiting add", async () => {
await renderNewEntry("vern", "gloss");

// Use a mock timer to control when addNewEntry completes
jest.useFakeTimers();
mockAddNewEntry.mockImplementation(
async () => await new Promise((res) => setTimeout(res, 1000))
);

// Submit a new entry
await act(async () => {
renderer.root.findByType(GlossWithSuggestions).props.handleEnter();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
expect(mockResetNewEntry).not.toHaveBeenCalled();

// Run the timers and confirm a reset
await act(async () => {
jest.runAllTimers();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
expect(mockResetNewEntry).toHaveBeenCalledTimes(1);

jest.useRealTimers();
});

it("doesn't allow double submission", async () => {
await renderNewEntry("vern", "gloss");

// Use a mock timer to control when addNewEntry completes
jest.useFakeTimers();
mockAddNewEntry.mockImplementation(
async () => await new Promise((res) => setTimeout(res, 1000))
);

// Submit a new entry
const gloss = renderer.root.findByType(GlossWithSuggestions);
await act(async () => {
gloss.props.handleEnter();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
expect(mockResetNewEntry).not.toHaveBeenCalled();

// Attempt a second submission before the first one completes
await act(async () => {
gloss.props.handleEnter();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
expect(mockResetNewEntry).not.toHaveBeenCalled();

// Run the timers and confirm no second submission
await act(async () => {
jest.runAllTimers();
});
expect(mockAddNewEntry).toHaveBeenCalledTimes(1);
expect(mockResetNewEntry).toHaveBeenCalledTimes(1);

jest.useRealTimers();
});
});

0 comments on commit 6ea0515

Please sign in to comment.