diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index db872fcdf5..08478b014c 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -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, @@ -103,6 +109,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { const [senseOpen, setSenseOpen] = useState(false); const [shouldFocus, setShouldFocus] = useState(); + const [submitting, setSubmitting] = useState(false); const [vernOpen, setVernOpen] = useState(false); const [wasTreeClosed, setWasTreeClosed] = useState(false); @@ -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]); @@ -169,6 +177,11 @@ export default function NewEntry(props: NewEntryProps): ReactElement { }; const addNewEntryAndReset = async (): Promise => { + // Prevent double-submission + if (submitting) { + return; + } + setSubmitting(true); await addNewEntry(); resetState(); }; @@ -228,7 +241,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { }; return ( - + handleEnter(true)} vernacularLang={vernacularLang} - textFieldId={`${idAffix}-vernacular`} + textFieldId={NewEntryId.TextFieldVern} onUpdate={() => conditionalFocus(FocusTarget.Vernacular)} /> handleEnter(false)} analysisLang={analysisLang} - textFieldId={`${idAffix}-gloss`} + textFieldId={NewEntryId.TextFieldGloss} onUpdate={() => conditionalFocus(FocusTarget.Gloss)} /> @@ -289,9 +302,9 @@ export default function NewEntry(props: NewEntryProps): ReactElement { {!selectedDup?.id && ( // note is not available if user selected to modify an exiting entry )} @@ -306,8 +319,8 @@ export default function NewEntry(props: NewEntryProps): ReactElement { resetState()} - buttonId={`${idAffix}-delete`} /> diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index 113d5bfdeb..0214822a07 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -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 => { + await act(async () => { + renderer = create( + + ()} + // Parent component handles vern suggestion state: + setSelectedDup={mockSetSelectedDup} + setSelectedSense={mockSetSelectedSense} + suggestedVerns={[]} + suggestedDups={[]} + /> + + ); + }); +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + describe("NewEntry", () => { - it("renders without crashing", () => { - renderer.act(() => { - renderer.create( - - ()} - // Parent component handles vern suggestion state: - setSelectedDup={jest.fn()} - setSelectedSense={jest.fn()} - suggestedVerns={[]} - suggestedDups={[]} - /> - - ); + 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(); }); });