From 9ff37edb0c54be2a091a50f681c717dab1410a37 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Tue, 23 May 2023 16:17:55 -0400 Subject: [PATCH] [DataEntryTable] NewEntry refactor (#2166) --- public/locales/ar/translation.json | 1 - public/locales/en/translation.json | 2 +- .../DataEntryTable/DataEntryTable.tsx | 438 +++++++---- .../VernWithSuggestions.tsx | 68 +- .../DataEntryTable/NewEntry/NewEntry.tsx | 691 +++++++----------- .../DataEntryTable/NewEntry/SenseDialog.tsx | 85 +-- .../DataEntryTable/NewEntry/StyledMenuItem.ts | 16 + .../DataEntryTable/NewEntry/VernDialog.tsx | 33 +- .../NewEntry/tests/NewEntry.test.tsx | 39 +- .../NewEntry/tests/VernDialog.test.tsx | 6 +- .../tests/DataEntryTable.test.tsx | 126 ++-- 11 files changed, 713 insertions(+), 792 deletions(-) create mode 100644 src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index 8c6a91c2bf..20c6c0706d 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -7,7 +7,6 @@ "selectSense": "حدد الإحساس", "newEntryFor": "إدخال جديد لـ: ", "newSenseFor": "معنى جديد عن: ", - "senseInWord": "الكلمة لها هذا المعنى بالفعل في هذا المجال الدلالي", "deleteRow": "احذف هذا الصف", "deleteRowWarning": "سيتم حذف هذا الصف بشكل دائم!", "addNote": "إضافة ملاحظة", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2c29084e18..77415ad341 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -23,7 +23,7 @@ "selectSense": "Select a sense", "newEntryFor": "New entry for: ", "newSenseFor": "New sense for: ", - "senseInWord": "The word already has this sense in this semantic domain", + "senseInWord": "The word already has this sense in this semantic domain: {{ val1 }}, {{ val2 }}", "deleteRow": "Delete this row", "deleteRowWarning": "This row will be permanently deleted!", "addNote": "Add a note", diff --git a/src/components/DataEntry/DataEntryTable/DataEntryTable.tsx b/src/components/DataEntry/DataEntryTable/DataEntryTable.tsx index 41278df3bc..a7544143eb 100644 --- a/src/components/DataEntry/DataEntryTable/DataEntryTable.tsx +++ b/src/components/DataEntry/DataEntryTable/DataEntryTable.tsx @@ -5,8 +5,10 @@ import { FormEvent, Fragment, ReactElement, + RefObject, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -16,26 +18,25 @@ import { v4 } from "uuid"; import { AutocompleteSetting, Note, - Project, SemanticDomain, SemanticDomainTreeNode, Sense, Status, Word, - WritingSystem, } from "api/models"; import * as backend from "backend"; import { getUserId } from "backend/localStorage"; -import NewEntry, { - FocusTarget, -} from "components/DataEntry/DataEntryTable/NewEntry/NewEntry"; +import NewEntry from "components/DataEntry/DataEntryTable/NewEntry/NewEntry"; import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry/RecentEntry"; import { getFileNameForWord } from "components/Pronunciations/AudioRecorder"; import Recorder from "components/Pronunciations/Recorder"; +import { StoreState } from "types"; import { Hash } from "types/hash"; +import { useAppSelector } from "types/hooks"; import theme from "types/theme"; -import { newSense, simpleWord } from "types/word"; -import { defaultWritingSystem, newWritingSystem } from "types/writingSystem"; +import { newNote, newSense, newWord, simpleWord } from "types/word"; +import { defaultWritingSystem } from "types/writingSystem"; +import { LevenshteinDistance } from "utilities/utilities"; import { firstGlossText } from "utilities/wordUtilities"; export const exitButtonId = "exit-to-domain-tree"; @@ -64,22 +65,6 @@ enum DefunctStatus { Retire = "RETIRE", } -interface DataEntryTableState { - // project properties - analysisLang: WritingSystem; - suggestVerns: boolean; - vernacularLang: WritingSystem; - // word data - existingWords: Word[]; - recentWords: WordAccess[]; - // state management - defunctUpdates: Hash; - defunctWordIds: Hash; - isFetchingFrontier: boolean; - isReady: boolean; - senseSwitches: SenseSwitch[]; -} - /*** Add current semantic domain to specified sense within a word. */ export function addSemanticDomainToSense( semDom: SemanticDomain, @@ -104,6 +89,45 @@ function filterWords(words: Word[]): Word[] { ); } +/*** Focus on a specified object. */ +export function focusInput(ref: RefObject): void { + if (ref.current) { + ref.current.focus(); + ref.current.scrollIntoView({ behavior: "smooth" }); + } +} + +/*** Find suggestions for given text from a list of strings. */ +const getSuggestions = ( + text: string, + all: string[], + dist: (a: string, b: string) => number +): string[] => { + if (!text || !all.length) { + return []; + } + const maxSuggestions = 5; + const maxDistance = 3; + + const some = all + .filter((s) => s.startsWith(text)) + .sort((a, b) => a.length - b.length); + // Take 2 shortest and the rest longest (should make finding the long words easier). + if (some.length > maxSuggestions) { + some.splice(2, some.length - maxSuggestions); + } + + if (some.length < maxSuggestions) { + const viable = all + .filter((s) => dist(s, text) < maxDistance && !some.includes(s)) + .sort((a, b) => dist(a, text) - dist(b, text)); + while (some.length < maxSuggestions && viable.length) { + some.push(viable.shift()!); + } + } + return some; +}; + /*** Return a copy of the semantic domain with current UserId and timestamp. */ export function makeSemDomCurrent(semDom: SemanticDomain): SemanticDomain { const created = new Date().toISOString(); @@ -155,29 +179,64 @@ export function updateEntryGloss( return { ...entry.word, senses }; } +interface DataEntryTableState { + // word data + allVerns: string[]; + allWords: Word[]; + recentWords: WordAccess[]; + // state management + defunctUpdates: Hash; + defunctWordIds: Hash; + isFetchingFrontier: boolean; + senseSwitches: SenseSwitch[]; + // new entry state + newAudioUrls: string[]; + newGloss: string; + newNote: string; + newVern: string; + selectedDup?: Word; + suggestedVerns: string[]; + suggestedDups: Word[]; +} + /*** A data entry table containing recent word entries. */ export default function DataEntryTable( props: DataEntryTableProps ): ReactElement { + const { analysisLang, suggestVerns, vernacularLang } = useAppSelector( + (state: StoreState) => { + const proj = state.currentProjectState.project; + return { + analysisLang: proj.analysisWritingSystems[0] ?? defaultWritingSystem, + suggestVerns: proj.autocompleteSetting === AutocompleteSetting.On, + vernacularLang: proj.vernacularWritingSystem, + }; + } + ); + const [state, setState] = useState({ - // project properties to be set - analysisLang: defaultWritingSystem, - suggestVerns: true, - vernacularLang: newWritingSystem("qaa", "Unknown"), // word data - existingWords: [], + allVerns: [], + allWords: [], recentWords: [], // state management defunctUpdates: {}, defunctWordIds: {}, - isFetchingFrontier: false, - isReady: false, + isFetchingFrontier: true, senseSwitches: [], + // new entry state + newAudioUrls: [], + newGloss: "", + newNote: "", + newVern: "", + suggestedVerns: [], + suggestedDups: [], }); const { enqueueSnackbar } = useSnackbar(); - const recorder = new Recorder(); - const refNewEntry = useRef(null); + const levDist = useMemo(() => new LevenshteinDistance(), []); + const newVernInput = useRef(null); + const recorder = useMemo(() => new Recorder(), []); const { t } = useTranslation(); //////////////////////////////////// @@ -185,19 +244,6 @@ export default function DataEntryTable( // These are preferably non-async function that return void. //////////////////////////////////// - /*** Apply language and autocomplete setting from the project. - * Then trigger the initial fetch of frontier data. - */ - const applyProjSettings = useCallback((proj: Project): void => { - setState((prevState) => ({ - ...prevState, - analysisLang: proj.analysisWritingSystems[0] ?? defaultWritingSystem, - isFetchingFrontier: true, - suggestVerns: proj.autocompleteSetting === AutocompleteSetting.On, - vernacularLang: proj.vernacularWritingSystem, - })); - }, []); - /*** Use this without newId before updating any word on the backend, * to make sure that word doesn't get edited by two different functions. * Use this with newId to specify the replacement of a defunct word. @@ -223,13 +269,6 @@ export default function DataEntryTable( }); }; - /*** Update whether the exit button is highlighted. */ - const setIsReady = (isReady: boolean): void => { - if (isReady !== state.isReady) { - setState((prevState) => ({ ...prevState, isReady })); - } - }; - /*** Update a recent entry to a different sense of the same word. */ const switchSense = useCallback( (oldGuid: string, newGuid: string): void => { @@ -306,19 +345,75 @@ export default function DataEntryTable( }); }; + /*** Add an audio file to newAudioUrls. */ + const addNewAudioUrl = (file: File): void => { + setState((prevState) => { + const newAudioUrls = [...prevState.newAudioUrls]; + newAudioUrls.push(URL.createObjectURL(file)); + return { ...prevState, newAudioUrls }; + }); + }; + + /*** Delete a url from newAudioUrls. */ + const delNewAudioUrl = (url: string): void => { + setState((prevState) => { + const newAudioUrls = prevState.newAudioUrls.filter((u) => u !== url); + return { ...prevState, newAudioUrls }; + }); + }; + + /*** Set the new entry gloss def. */ + const setNewGloss = (gloss: string): void => { + if (gloss !== state.newGloss) { + setState((prev) => ({ ...prev, newGloss: gloss })); + } + }; + + /*** Set the new entry note text. */ + const setNewNote = (note: string): void => { + if (note !== state.newNote) { + setState((prev) => ({ ...prev, newNote: note })); + } + }; + + /*** Set the new entry vernacular. */ + const setNewVern = (vern: string): void => { + if (vern !== state.newVern) { + setState((prev) => ({ ...prev, newVern: vern })); + } + }; + + /*** Set or clear the selected vern-duplicate word. */ + const setSelectedDup = (id?: string): void => { + setState((prev) => ({ + ...prev, + selectedDup: id + ? prev.suggestedDups.find((w) => w.id === id) + : id === "" + ? newWord(prev.newVern) + : undefined, + })); + }; + /*** Reset things specific to the current data entry session in the current semantic domain. */ const resetEverything = (): void => { + props.openTree(); props.hideQuestions(); setState((prevState) => ({ ...prevState, defunctUpdates: {}, defunctWordIds: {}, - senseSwitches: [], recentWords: [], + senseSwitches: [], + // new entry state: + newAudioUrls: [], + newGloss: "", + newNote: "", + newVern: "", + selectedDup: undefined, + suggestedDups: [], + suggestedVerns: [], })); - if (refNewEntry.current) { - refNewEntry.current.resetState(); - } }; //////////////////////////////////// @@ -326,11 +421,6 @@ export default function DataEntryTable( // These cannot be async, so use asyncFunction().then(...) as needed. //////////////////////////////////// - /*** Happens once on initial render to load in projectSettings. */ - useEffect(() => { - backend.getProject().then(applyProjSettings); - }, [applyProjSettings]); - /*** Manages the senseSwitches queue. */ useEffect(() => { if (!state.senseSwitches.length) { @@ -348,12 +438,12 @@ export default function DataEntryTable( }, [switchSense, state.recentWords, state.senseSwitches]); /*** Manages fetching the frontier. - * This is the ONLY place to update existingWords or switch isFetchingFrontier to false. - */ + * This is the ONLY place to update allWords and allVerns + * or to switch isFetchingFrontier to false. */ useEffect(() => { if (state.isFetchingFrontier) { backend.getFrontierWords().then((words) => { - const existingWords = filterWords(words); + const allWords = filterWords(words); setState((prevState) => { const defunctWordIds: Hash = {}; for (const id of Object.keys(prevState.defunctWordIds)) { @@ -377,7 +467,7 @@ export default function DataEntryTable( return { ...prevState, isFetchingFrontier: false, - existingWords, + allWords, defunctUpdates, defunctWordIds, }; @@ -386,6 +476,16 @@ export default function DataEntryTable( } }, [state.isFetchingFrontier]); + /*** If vern-autocomplete is on for the project, make list of all vernaculars. */ + useEffect(() => { + setState((prev) => ({ + ...prev, + allVerns: suggestVerns + ? [...new Set(prev.allWords.map((w) => w.vernacular))] + : [], + })); + }, [state.allWords, suggestVerns]); + /*** Act on the defunctUpdates queue. */ useEffect(() => { const ids = Object.keys(state.defunctUpdates); @@ -408,6 +508,29 @@ export default function DataEntryTable( } }, [state.defunctUpdates, state.recentWords]); + /*** Update vern suggestions. */ + useEffect(() => { + setState((prev) => { + const trimmed = prev.newVern.trim(); + return { + ...prev, + selectedDup: undefined, + suggestedDups: trimmed + ? prev.allWords.filter( + (w) => + w.vernacular.trim() === trimmed && + !Object.keys(prev.defunctWordIds).includes(w.id) + ) + : [], + suggestedVerns: getSuggestions( + prev.newVern, + prev.allVerns, + (a: string, b: string) => levDist.getDistance(a, b) + ), + }; + }); + }, [levDist, state.newVern]); + //////////////////////////////////// // Async functions that wrap around a backend update to a word. // Before the update, defunctWord(word.id). @@ -489,17 +612,17 @@ export default function DataEntryTable( return newWord; }; - ////////////////////////// - // Other functions. + ///////////////////////////////// + // General async functions. ///////////////////////////////// + /*** Add a new word to the project, or update if new word is a duplicate. */ const addNewWord = async ( wordToAdd: Word, audioURLs: string[], insertIndex?: number - //ignoreRecent?: boolean, ): Promise => { - wordToAdd.note.language = state.analysisLang.bcp47; + wordToAdd.note.language = analysisLang.bcp47; // Check if word is duplicate to existing word. const dupId = await backend.getDuplicateId(wordToAdd); @@ -509,17 +632,13 @@ export default function DataEntryTable( let word = await backend.createWord(wordToAdd); const wordId = await addAudiosToBackend(word.id, audioURLs); - // ToDo: Evaluate if the removed `ignoreRecent` functionality is still needed. - /*if (ignoreRecent) { - return; - }*/ if (wordId !== word.id) { word = await backend.getWord(wordId); } addToDisplay({ word, senseGuid: word.senses[0].guid }, insertIndex); }; - /*** Update the word in the backend and the frontend */ + /*** Update the word in the backend and the frontend. */ const updateWordBackAndFront = async ( wordToUpdate: Word, senseGuid: string, @@ -533,49 +652,86 @@ export default function DataEntryTable( addToDisplay({ word, senseGuid }); }; + /*** Reset the entry table. If there is an un-submitted word then submit it. */ + const handleExit = async (): Promise => { + // Check if there is a new word, but user exited without pressing enter. + if (state.newVern) { + const oldWord = state.allWords.find( + (w) => w.vernacular === state.newVern + ); + if (!oldWord) { + // Existing word not found, so create a new word. + addNewEntry(); + } else { + // Found an existing word, so add a sense to it. + await updateWordWithNewEntry(oldWord.id); + } + } + resetEverything(); + }; + + ///////////////////////////////// + // Async functions for handling changes of the NewEntry. + ///////////////////////////////// + + /*** Assemble a word from the new entry state and add it. */ + const addNewEntry = async (): Promise => { + const word = newWord(state.newVern); + const lang = analysisLang.bcp47; + word.senses.push(newSense(state.newGloss, lang, props.semanticDomain)); + word.note = newNote(state.newNote, lang); + await addNewWord(word, state.newAudioUrls); + }; + /*** Checks if sense already exists with this gloss and semantic domain. */ - const updateWordWithNewGloss = async ( - wordId: string, - gloss: string, - audioFileURLs?: string[] - ): Promise => { - const existingWord = state.existingWords.find((w: Word) => w.id === wordId); - if (!existingWord) { + const updateWordWithNewEntry = async (wordId: string): Promise => { + const oldWord = state.allWords.find((w: Word) => w.id === wordId); + if (!oldWord) { throw new Error("You are trying to update a nonexistent word"); } + const semDom = makeSemDomCurrent(props.semanticDomain); - for (const sense of existingWord.senses) { - if (sense.glosses?.length && sense.glosses[0].def === gloss) { - if ( - sense.semanticDomains.find((d) => d.id === props.semanticDomain.id) - ) { + // If this gloss matches a sense on the word, update that sense. + for (const sense of oldWord.senses) { + if (sense.glosses?.length && sense.glosses[0].def === state.newGloss) { + if (sense.semanticDomains.find((d) => d.id === semDom.id)) { // User is trying to add a sense that already exists enqueueSnackbar( - `${t("addWords.senseInWord")}: ${existingWord.vernacular}, ${gloss}` + t("addWords.senseInWord", { + val1: oldWord.vernacular, + val2: state.newGloss, + }) ); + if (state.newAudioUrls.length) { + await addAudiosToBackend(wordId, state.newAudioUrls); + } return; } else { - const updatedWord = addSemanticDomainToSense( - props.semanticDomain, - existingWord, - sense.guid + await updateWordBackAndFront( + addSemanticDomainToSense(semDom, oldWord, sense.guid), + sense.guid, + state.newAudioUrls ); - await updateWordBackAndFront(updatedWord, sense.guid, audioFileURLs); return; } } } + // The gloss is new for this word, so add a new sense. - defunctWord(existingWord.id); - const semDom = makeSemDomCurrent(props.semanticDomain); - const sense = newSense(gloss, state.analysisLang.bcp47, semDom); - const senses = [...existingWord.senses, sense]; - const newWord: Word = { ...existingWord, senses }; + defunctWord(oldWord.id); + const sense = newSense(state.newGloss, analysisLang.bcp47, semDom); + const senses = [...oldWord.senses, sense]; + const newWord: Word = { ...oldWord, senses }; - await updateWordBackAndFront(newWord, sense.guid, audioFileURLs); + await updateWordBackAndFront(newWord, sense.guid, state.newAudioUrls); return; }; + ///////////////////////////////// + // Async functions for handling changes of a RecentEntry. + ///////////////////////////////// + + /*** Retract a recent entry. */ const undoRecentEntry = async (eIndex: number): Promise => { const { word, senseGuid } = state.recentWords[eIndex]; const sIndex = word.senses.findIndex((s) => s.guid === senseGuid); @@ -603,6 +759,7 @@ export default function DataEntryTable( } }; + /*** Update the vernacular in a recent entry. */ const updateRecentVern = async ( index: number, vernacular: string, @@ -630,6 +787,7 @@ export default function DataEntryTable( } }; + /*** Update the gloss def in a recent entry. */ const updateRecentGloss = async ( index: number, def: string @@ -650,6 +808,7 @@ export default function DataEntryTable( } }; + /*** Update the note text in a recent entry. */ const updateRecentNote = async ( index: number, text: string @@ -661,37 +820,6 @@ export default function DataEntryTable( } }; - /*** Reset the entry table. If there is an un-submitted word then submit it. */ - const handleExit = async (): Promise => { - // Check if there is a new word, but user exited without pressing enter - if (refNewEntry.current) { - const newEntry = refNewEntry.current.state.newEntry; - if (newEntry?.vernacular) { - const existingWord = state.existingWords.find( - (w) => w.vernacular === newEntry.vernacular - ); - // existing word not found, create a new word - if (!existingWord) { - if (!newEntry.senses.length) { - newEntry.senses.push( - newSense(undefined, undefined, props.semanticDomain) - ); - } - const newEntryAudio = refNewEntry.current.state.audioFileURLs; - await addNewWord(newEntry, newEntryAudio); - } else { - // found an existing word, update it - await updateWordWithNewGloss( - existingWord.id, - newEntry.senses[0].glosses[0].def, - refNewEntry.current.state.audioFileURLs - ); - } - } - } - resetEverything(); - }; - return (
) => e?.preventDefault()}> @@ -741,13 +869,9 @@ export default function DataEntryTable( deleteAudioFromWord(wordId, fileName) } recorder={recorder} - focusNewEntry={() => { - if (refNewEntry.current) { - refNewEntry.current.focus(FocusTarget.Vernacular); - } - }} - analysisLang={state.analysisLang} - vernacularLang={state.vernacularLang} + focusNewEntry={() => focusInput(newVernInput)} + analysisLang={analysisLang} + vernacularLang={vernacularLang} disabled={Object.keys(state.defunctWordIds).includes( wordAccess.word.id )} @@ -757,22 +881,27 @@ export default function DataEntryTable( updateWordWithNewGloss(wordId, gloss, audioFileURLs)} - addNewWord={(word: Word, audioFileURLs: string[]) => - addNewWord(word, audioFileURLs) - } - semanticDomain={makeSemDomCurrent(props.semanticDomain)} - setIsReadyState={(isReady: boolean) => setIsReady(isReady)} recorder={recorder} - analysisLang={state.analysisLang} - vernacularLang={state.vernacularLang} + analysisLang={analysisLang} + vernacularLang={vernacularLang} + // Parent handles new entry state of child: + addNewEntry={addNewEntry} + updateWordWithNewGloss={updateWordWithNewEntry} + newAudioUrls={state.newAudioUrls} + addNewAudioUrl={addNewAudioUrl} + delNewAudioUrl={delNewAudioUrl} + newGloss={state.newGloss} + setNewGloss={setNewGloss} + newNote={state.newNote} + setNewNote={setNewNote} + newVern={state.newVern} + setNewVern={setNewVern} + vernInput={newVernInput} + // Parent handles vern suggestion state of child: + selectedDup={state.selectedDup} + setSelectedDup={setSelectedDup} + suggestedDups={state.suggestedDups} + suggestedVerns={state.suggestedVerns} /> @@ -796,14 +925,11 @@ export default function DataEntryTable( id={exitButtonId} type="submit" variant="contained" - color={state.isReady ? "primary" : "secondary"} + color={state.newVern.trim() ? "primary" : "secondary"} style={{ marginTop: theme.spacing(2) }} endIcon={} tabIndex={-1} - onClick={() => { - props.openTree(); - handleExit(); - }} + onClick={handleExit} > {t("buttons.exit")} diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx index 79cbb4c7eb..de1f3f1c77 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx @@ -36,40 +36,38 @@ export default function VernWithSuggestions( }); return ( - - { - // onChange is triggered when an option is selected - props.updateVernField(value ?? "", true); - }} - onInputChange={(_e, value) => { - // onInputChange is triggered by typing - props.updateVernField(value); - }} - onKeyPress={(e: React.KeyboardEvent) => { - if (e.key === Key.Enter || e.key === Key.Tab) { - e.preventDefault(); - props.handleEnterAndTab(e); - } - }} - onClose={props.onClose} - renderInput={(params) => ( - - )} - /> - + { + // onChange is triggered when an option is selected + props.updateVernField(value ?? "", true); + }} + onInputChange={(_e, value) => { + // onInputChange is triggered by typing + props.updateVernField(value); + }} + onKeyPress={(e: React.KeyboardEvent) => { + if (e.key === Key.Enter || e.key === Key.Tab) { + e.preventDefault(); + props.handleEnterAndTab(e); + } + }} + onClose={props.onClose} + renderInput={(params) => ( + + )} + /> ); } diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/NewEntry.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/NewEntry.tsx index 5543e68541..a2a958fc30 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/NewEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/NewEntry.tsx @@ -1,9 +1,20 @@ import { AutocompleteCloseReason, Grid, Typography } from "@mui/material"; -import React, { ReactElement } from "react"; +import { + CSSProperties, + KeyboardEvent, + ReactElement, + RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { Key } from "ts-key-enum"; -import { SemanticDomain, Word, WritingSystem } from "api/models"; +import { Word, WritingSystem } from "api/models"; +import { focusInput } from "components/DataEntry/DataEntryTable/DataEntryTable"; import { DeleteEntry, EntryNote, @@ -14,503 +25,301 @@ import SenseDialog from "components/DataEntry/DataEntryTable/NewEntry/SenseDialo import VernDialog from "components/DataEntry/DataEntryTable/NewEntry/VernDialog"; import Pronunciations from "components/Pronunciations/PronunciationsComponent"; import Recorder from "components/Pronunciations/Recorder"; +import { StoreState } from "types"; import theme from "types/theme"; -import { newSense, newWord } from "types/word"; -import { LevenshteinDistance } from "utilities/utilities"; -import { firstGlossText } from "utilities/wordUtilities"; const idAffix = "new-entry"; -interface NewEntryProps { - allWords: Word[]; - defunctWordIds: string[]; - updateWordWithNewGloss: ( - wordId: string, - gloss: string, - audioFileURLs: string[] - ) => void; - addNewWord: (newEntry: Word, newAudio: string[]) => void; - semanticDomain: SemanticDomain; - setIsReadyState: (isReady: boolean) => void; - recorder?: Recorder; - analysisLang: WritingSystem; - vernacularLang: WritingSystem; -} - export enum FocusTarget { Gloss, Vernacular, } -interface NewEntryState { - newEntry: Word; - allVerns: string[]; - suggestedVerns: string[]; - dupVernWords: Word[]; - activeGloss: string; - audioFileURLs: string[]; - vernOpen: boolean; - senseOpen: boolean; - selectedWord?: Word; - shouldFocus?: FocusTarget; -} +const gridItemStyle = (spacing: number): CSSProperties => ({ + paddingLeft: theme.spacing(spacing), + paddingRight: theme.spacing(spacing), + position: "relative", +}); -function focusInput(inputRef: React.RefObject) { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.scrollIntoView({ behavior: "smooth" }); - } +interface NewEntryProps { + analysisLang: WritingSystem; + vernacularLang: WritingSystem; + recorder?: Recorder; + // Parent component handles new entry state: + addNewEntry: () => Promise; + updateWordWithNewGloss: (wordId: string) => Promise; + newAudioUrls: string[]; + addNewAudioUrl: (file: File) => void; + delNewAudioUrl: (url: string) => void; + newGloss: string; + setNewGloss: (gloss: string) => void; + newNote: string; + setNewNote: (note: string) => void; + newVern: string; + setNewVern: (vern: string) => void; + vernInput: RefObject; + // Parent component handles vern suggestion state: + selectedDup?: Word; + setSelectedDup: (id?: string) => void; + suggestedVerns: string[]; + suggestedDups: Word[]; } /** * Displays data related to creating a new word entry */ -export default class NewEntry extends React.Component< - NewEntryProps, - NewEntryState -> { - private readonly levDistance = new LevenshteinDistance(); - private readonly maxSuggestions = 5; - private readonly maxLevDistance = 3; +export default function NewEntry(props: NewEntryProps): ReactElement { + const { + analysisLang, + vernacularLang, + recorder, + // Parent component handles new entry state: + addNewEntry, + updateWordWithNewGloss, + newAudioUrls, + addNewAudioUrl, + delNewAudioUrl, + newGloss, + setNewGloss, + newNote, + setNewNote, + newVern, + setNewVern, + vernInput, + // Parent component handles vern suggestion state: + selectedDup, + setSelectedDup, + suggestedVerns, + suggestedDups, + } = props; + + const isTreeOpen = useSelector( + (state: StoreState) => state.treeViewState.open + ); - constructor(props: NewEntryProps) { - super(props); - this.state = { - newEntry: newWord(), - activeGloss: "", - allVerns: [], - audioFileURLs: [], - suggestedVerns: [], - dupVernWords: [], - vernOpen: false, - senseOpen: false, - }; - this.vernInput = React.createRef(); - this.glossInput = React.createRef(); - } + const [senseOpen, setSenseOpen] = useState(false); + const [shouldFocus, setShouldFocus] = useState(); + const [vernOpen, setVernOpen] = useState(false); + const [wasTreeClosed, setWasTreeClosed] = useState(false); - vernInput: React.RefObject; - glossInput: React.RefObject; + const glossInput = useRef(null); - async componentDidUpdate( - prevProps: NewEntryProps, - prevState: NewEntryState - ): Promise { - if (prevProps.allWords !== this.props.allWords) { - this.setState((_, props) => { - const vernsWithDups = props.allWords.map((w: Word) => w.vernacular); - return { allVerns: [...new Set(vernsWithDups)] }; - }); - } + const focus = useCallback( + (target: FocusTarget): void => { + switch (target) { + case FocusTarget.Gloss: + focusInput(glossInput); + return; + case FocusTarget.Vernacular: + focusInput(vernInput); + return; + } + }, + [glossInput, vernInput] + ); - /* When the vern/sense dialogs are closed, focus needs to return to text - fields. The following sets a flag (state.shouldFocus) to trigger focus once - the input components are updated. Focus is triggered by - this.conditionalFocus() passed to each input component and called within its - respective componentDidUpdate(). */ - if ( - (prevState.vernOpen || prevState.senseOpen) && - !(this.state.vernOpen || this.state.senseOpen) - ) { - this.setState((state: NewEntryState) => ({ - shouldFocus: state.selectedWord - ? FocusTarget.Gloss - : FocusTarget.Vernacular, - })); - } - } + const resetState = useCallback((): void => { + setNewGloss(""); + setNewNote(""); + setNewVern(""); + setVernOpen(false); + // May also need to reset newAudioUrls in the parent component. + focus(FocusTarget.Vernacular); + }, [focus, setNewGloss, setNewNote, setNewVern, setVernOpen]); - focus(target: FocusTarget): void { - switch (target) { - case FocusTarget.Gloss: - focusInput(this.glossInput); - return; - case FocusTarget.Vernacular: - focusInput(this.vernInput); - return; + /** Reset when tree opens, except for the first time it is open. */ + useEffect(() => { + if (isTreeOpen) { + if (wasTreeClosed) { + resetState(); + } + setWasTreeClosed(false); + } else { + setWasTreeClosed(true); } - } + }, [isTreeOpen, resetState, wasTreeClosed]); - /** This function is for a child input component to call in componentDidUpdate - * to move focus to itself, if the current state.shouldFocus says it should. */ - conditionalFocus(target: FocusTarget): void { - if (this.state.shouldFocus === target) { - this.focus(target); - this.setState({ shouldFocus: undefined }); + /** When the vern/sense dialogs are closed, focus needs to return to text fields. + * The following sets a flag (shouldFocus) to be triggered by conditionalFocus(), + * which is passed to each input component to call on update. */ + useEffect(() => { + if (!(senseOpen || vernOpen)) { + setShouldFocus(selectedDup ? FocusTarget.Gloss : FocusTarget.Vernacular); } - } - - addAudio(audioFile: File): void { - this.setState((prevState) => { - const audioFileURLs = [...prevState.audioFileURLs]; - audioFileURLs.push(URL.createObjectURL(audioFile)); - return { audioFileURLs }; - }); - } + }, [selectedDup, senseOpen, vernOpen]); - removeAudio(fileName: string): void { - this.setState((prevState) => ({ - audioFileURLs: prevState.audioFileURLs.filter( - (fileURL) => fileURL !== fileName - ), - })); - } - - updateGlossField(newValue: string): void { - this.setState((prevState, props) => ({ - newEntry: { - ...prevState.newEntry, - senses: [ - newSense(newValue, props.analysisLang.bcp47, props.semanticDomain), - ], - }, - activeGloss: newValue, - })); - } - - updateVernField(newValue: string, openDialog?: boolean): void { - const stateUpdates: Partial = {}; - if (newValue !== this.state.newEntry.vernacular) { - if (this.state.selectedWord) { - this.setState({ selectedWord: undefined }); - } - this.props.setIsReadyState(newValue.trim().length > 0); - this.updateSuggestedVerns(newValue); - let dupVernWords: Word[] = []; - if (newValue) { - dupVernWords = this.props.allWords.filter( - (word) => - word.vernacular === newValue && - !this.props.defunctWordIds.includes(word.id) - // Weed out any words that are already being edited - ); - } - stateUpdates.dupVernWords = dupVernWords; - stateUpdates.newEntry = { ...this.state.newEntry, vernacular: newValue }; + /** This function is for a child input component to call on update + * to move focus to itself, if shouldFocus says it should. */ + const conditionalFocus = (target: FocusTarget): void => { + if (shouldFocus === target) { + focus(target); + setShouldFocus(undefined); } - this.setState(stateUpdates as NewEntryState, () => { - if ( - openDialog && - this.state.dupVernWords.length && - !this.state.selectedWord - ) { - this.setState({ vernOpen: true }); - } - }); - } - - updateNote(text: string): void { - this.setState((prevState, props) => ({ - newEntry: { - ...prevState.newEntry, - note: { text, language: props.analysisLang.bcp47 }, - }, - })); - } + }; - resetState(): void { - this.setState({ - newEntry: newWord(), - activeGloss: "", - audioFileURLs: [], - suggestedVerns: [], - dupVernWords: [], - selectedWord: undefined, - }); - this.focus(FocusTarget.Vernacular); - } + const updateVernField = (vern: string, openVernDialog?: boolean): void => { + setNewVern(vern); + setVernOpen(!!openVernDialog); + }; - addNewWordAndReset(): void { - const newEntry: Word = this.state.newEntry.senses.length - ? this.state.newEntry - : { - ...this.state.newEntry, - senses: [ - newSense( - "", - this.props.analysisLang.bcp47, - this.props.semanticDomain - ), - ], - }; - this.props.addNewWord(newEntry, this.state.audioFileURLs); - this.resetState(); - } + const addNewEntryAndReset = async (): Promise => { + await addNewEntry(); + resetState(); + }; - addOrUpdateWord(): void { - if (this.state.dupVernWords.length) { + const addOrUpdateWord = async (): Promise => { + if (suggestedDups.length) { // Duplicate vern ... - if (!this.state.selectedWord) { + if (!selectedDup) { // ... and user hasn't made a selection - this.setState({ vernOpen: true }); - } else if (this.state.selectedWord.id) { + setVernOpen(true); + } else if (!selectedDup.id) { // ... and user has selected an entry to modify - this.props.updateWordWithNewGloss( - this.state.selectedWord.id, - this.state.activeGloss, - this.state.audioFileURLs - ); - this.resetState(); + await updateWordWithNewGloss(selectedDup.id); + resetState(); } else { // ... and user has selected new entry - this.addNewWordAndReset(); + await addNewEntryAndReset(); } } else { - // New Vern is typed - this.addNewWordAndReset(); + // New vern is typed + await addNewEntryAndReset(); } - } + }; - handleEnter(e: React.KeyboardEvent, checkGloss: boolean): void { - if (!this.state.vernOpen && e.key === Key.Enter) { + const handleEnter = async ( + e: KeyboardEvent, + checkGloss: boolean + ): Promise => { + console.info(vernOpen); + if ((true || !vernOpen) && e.key === Key.Enter) { // The user can never submit a new entry without a vernacular - if (this.state.newEntry.vernacular) { + if (newVern) { // The user can conditionally submit a new entry without a gloss - if (this.state.activeGloss || !checkGloss) { - this.addOrUpdateWord(); - this.focus(FocusTarget.Vernacular); + if (newGloss || !checkGloss) { + await addOrUpdateWord(); + focus(FocusTarget.Vernacular); } else { - this.focus(FocusTarget.Gloss); + focus(FocusTarget.Gloss); } } else { - this.focus(FocusTarget.Vernacular); + focus(FocusTarget.Vernacular); } } - } + }; - handleCloseVernDialog(selectedWordId?: string): void { - this.setState((prevState) => { - let selectedWord: Word | undefined; - let senseOpen = false; - if (selectedWordId === "") { - selectedWord = newWord(prevState.newEntry.vernacular); - } else if (selectedWordId) { - selectedWord = prevState.dupVernWords.find( - (word: Word) => word.id === selectedWordId - ); - senseOpen = true; - } - return { selectedWord, senseOpen, vernOpen: false }; - }); - } - - handleCloseSenseDialog(senseIndex?: number): void { - if (senseIndex === undefined) { - this.setState({ selectedWord: undefined, vernOpen: true }); - } else if (senseIndex >= 0) { - // SenseDialog can only be open when this.state.selectedWord is defined. - const gloss = firstGlossText(this.state.selectedWord!.senses[senseIndex]); - this.updateGlossField(gloss); - } // The remaining case, senseIndex===-1, indicates new sense for the selectedWord. - this.setState({ senseOpen: false }); - } - - autoCompleteCandidates(vernacular: string): string[] { - // filter allVerns to those that start with vernacular - // then map them into an array sorted by length and take the 2 shortest - // and the rest longest (should make finding the long words easier) - const scoredStartsWith: [string, number][] = []; - const startsWith = this.state.allVerns.filter((vern: string) => - vern.startsWith(vernacular) - ); - for (const v of startsWith) { - scoredStartsWith.push([v, v.length]); + const handleCloseVernDialog = (id?: string): void => { + if (id !== undefined) { + setSelectedDup(id); } - const keepers = scoredStartsWith - .sort((a, b) => a[1] - b[1]) - .map((vern) => vern[0]); - if (keepers.length > this.maxSuggestions) { - keepers.splice(2, keepers.length - this.maxSuggestions); + + if (id) { + setSenseOpen(true); } - return keepers; - } - updateSuggestedVerns(value?: string): void { - let suggestedVerns: string[] = []; - if (value) { - suggestedVerns = [...this.autoCompleteCandidates(value)]; - if (suggestedVerns.length < this.maxSuggestions) { - const viableVerns: string[] = this.state.allVerns.filter( - (vern: string) => - this.levDistance.getDistance(vern, value) < this.maxLevDistance - ); - const sortedVerns: string[] = viableVerns.sort( - (a: string, b: string) => - this.levDistance.getDistance(a, value) - - this.levDistance.getDistance(b, value) - ); - let candidate: string; - while ( - suggestedVerns.length < this.maxSuggestions && - sortedVerns.length - ) { - candidate = sortedVerns.shift()!; - if (!suggestedVerns.includes(candidate)) { - suggestedVerns.push(candidate); - } - } - } + setVernOpen(false); + }; + + const handleCloseSenseDialog = (gloss?: string): void => { + if (gloss) { + setNewGloss(gloss); + } else if (gloss === undefined) { + // If gloss is undefined, the user exited the dialog without a selection. + setSelectedDup(); + setVernOpen(true); } - this.setState({ suggestedVerns }); - } + // else: an empty string indicates new sense for the selectedWord. - render(): ReactElement { - return ( - - - - { - this.updateVernField(newValue, openDialog); - }} - onBlur={() => { - this.updateVernField(this.state.newEntry.vernacular, true); - }} - onClose={( - _: React.ChangeEvent<{}>, - reason: AutocompleteCloseReason - ): void => { - // Handle if the user fully types an identical vernacular to a - // suggestion and selects it from the Autocomplete. This should - // open the dialog. - switch (reason) { - case "selectOption": - // User pressed Enter or Left Click on an item. - this.updateVernField(this.state.newEntry.vernacular, true); - break; - default: - // If the user Escapes out of the Autocomplete, do nothing. - break; - } - }} - suggestedVerns={this.state.suggestedVerns} - handleEnterAndTab={(e: React.KeyboardEvent) => - // To prevent unintentional no-gloss submissions: - // If enter pressed from the vern field, - // check whether gloss is empty - this.handleEnter(e, true) - } - vernacularLang={this.props.vernacularLang} - textFieldId={`${idAffix}-vernacular`} - onUpdate={() => this.conditionalFocus(FocusTarget.Vernacular)} - /> - - this.handleCloseVernDialog(selectedWordId) - } - vernacularWords={this.state.dupVernWords} - analysisLang={this.props.analysisLang.bcp47} - /> - {this.state.selectedWord && ( - - this.handleCloseSenseDialog(senseIndex) - } - analysisLang={this.props.analysisLang.bcp47} - /> - )} - - - - + + + - this.updateGlossField(newValue) - } - handleEnterAndTab={(e: React.KeyboardEvent) => - // To allow intentional no-gloss submissions: - // If enter pressed from the gloss field, - // don't check whether gloss is empty - this.handleEnter(e, false) + vernacular={newVern} + vernInput={vernInput} + updateVernField={(newValue: string, openDialog?: boolean) => + updateVernField(newValue, openDialog) } - analysisLang={this.props.analysisLang} - textFieldId={`${idAffix}-gloss`} - onUpdate={() => this.conditionalFocus(FocusTarget.Gloss)} + onBlur={() => setVernOpen(true)} + onClose={(_, reason: AutocompleteCloseReason) => { + // Handle if the user fully types an identical vernacular to a suggestion + // and selects it from the Autocomplete. This should open the dialog. + if (reason === "selectOption") { + // User pressed Enter or Left Click on an item. + setVernOpen(true); + } + }} + suggestedVerns={suggestedVerns} + // To prevent unintentional no-gloss submissions: + // If enter pressed from the vern field, check whether gloss is empty + handleEnterAndTab={(e: KeyboardEvent) => handleEnter(e, true)} + vernacularLang={vernacularLang} + textFieldId={`${idAffix}-vernacular`} + onUpdate={() => conditionalFocus(FocusTarget.Vernacular)} /> - - - {!this.state.selectedWord?.id && ( - // note is not available if user selected to modify an exiting entry - this.updateNote(text)} - buttonId="note-entry-new" + + {selectedDup && ( + )} - - { - this.removeAudio(fileName); - }} - uploadAudio={(_, audioFile: File) => { - this.addAudio(audioFile); - }} - getAudioUrl={(_, fileName: string) => fileName} - /> - - - this.resetState()} - buttonId={`${idAffix}-delete`} + + + handleEnter(e, false)} + analysisLang={analysisLang} + textFieldId={`${idAffix}-gloss`} + onUpdate={() => conditionalFocus(FocusTarget.Gloss)} + /> + + + {!selectedDup?.id && ( + // note is not available if user selected to modify an exiting entry + - - + )} + + + delNewAudioUrl(fileName)} + uploadAudio={(_, audioFile: File) => addNewAudioUrl(audioFile)} + getAudioUrl={(_, fileName: string) => fileName} + /> - ); - } + + resetState()} + buttonId={`${idAffix}-delete`} + /> + + + + ); } function EnterGrid(): ReactElement { diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx index 124cd8702f..7f7aca32f7 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx @@ -1,15 +1,9 @@ -import { - Dialog, - DialogContent, - MenuItem, - MenuList, - Typography, -} from "@mui/material"; -import { withStyles } from "@mui/styles"; -import React from "react"; +import { Dialog, DialogContent, MenuList, Typography } from "@mui/material"; +import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Word } from "api/models"; +import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; import DomainCell from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; import theme from "types/theme"; @@ -18,11 +12,12 @@ import { firstGlossText } from "utilities/wordUtilities"; interface SenseDialogProps { selectedWord: Word; open: boolean; - handleClose: (senseIndex?: number) => void; + // Call handleClose with no input to indicate no selection was made. + handleClose: (gloss?: string) => void; analysisLang: string; } -export default function SenseDialog(props: SenseDialogProps) { +export default function SenseDialog(props: SenseDialogProps): ReactElement { return ( void; + closeDialog: (gloss: string) => void; analysisLang: string; } -// Copied from customized menus at https://material-ui.com/components/menus/ -export const StyledMenuItem = withStyles((theme) => ({ - root: { - "&:focus": { - backgroundColor: theme.palette.primary.main, - "& .MuiListItemIcon-root, & .MuiListItemText-primary": { - color: theme.palette.common.white, - }, - }, - }, -}))(MenuItem); - export function SenseList(props: SenseListProps) { const { t } = useTranslation(); return ( - + <> {t("addWords.selectSense")} - {props.selectedWord.senses.map((sense, index) => ( - props.closeDialog(index)} - key={firstGlossText(sense)} - id={firstGlossText(sense)} - > -
-

{firstGlossText(sense)}

-
-
- -
-
- ))} + {props.selectedWord.senses.map((sense) => { + const gloss = firstGlossText(sense); + return ( + props.closeDialog(gloss)} + key={gloss} + id={gloss} + > +
+

{gloss}

+
+
+ +
+
+ ); + })} - props.closeDialog(-1)}> + props.closeDialog("")}> {t("addWords.newSenseFor")} {props.selectedWord.vernacular}
-
+ ); } diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts new file mode 100644 index 0000000000..0410fedf5b --- /dev/null +++ b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts @@ -0,0 +1,16 @@ +import { MenuItem } from "@mui/material"; +import { withStyles } from "@mui/styles"; + +// Copied from customized menus at https://material-ui.com/components/menus/ +const StyledMenuItem = withStyles((theme) => ({ + root: { + "&:focus": { + backgroundColor: theme.palette.primary.main, + "& .MuiListItemIcon-root, & .MuiListItemText-primary": { + color: theme.palette.common.white, + }, + }, + }, +}))(MenuItem); + +export default StyledMenuItem; diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx index e62bc12194..487d6cc6b0 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx @@ -1,15 +1,9 @@ -import { - Dialog, - DialogContent, - MenuItem, - MenuList, - Typography, -} from "@mui/material"; -import { withStyles } from "@mui/styles"; -import React from "react"; +import { Dialog, DialogContent, MenuList, Typography } from "@mui/material"; +import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Word } from "api/models"; +import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; import DomainCell from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell"; import GlossCell from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/GlossCell"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; @@ -18,11 +12,12 @@ import theme from "types/theme"; interface vernDialogProps { vernacularWords: Word[]; open: boolean; + // Call handleClose with no input to indicate no selection was made. handleClose: (selectedWordId?: string) => void; analysisLang?: string; } -export default function VernDialog(props: vernDialogProps) { +export default function VernDialog(props: vernDialogProps): ReactElement { return ( void; + closeDialog: (wordId: string) => void; analysisLang?: string; } -// Copied from customized menus at https://material-ui.com/components/menus/ -export const StyledMenuItem = withStyles((theme) => ({ - root: { - "&:focus": { - backgroundColor: theme.palette.primary.main, - "& .MuiListItemIcon-root, & .MuiListItemText-primary": { - color: theme.palette.common.white, - }, - }, - }, -}))(MenuItem); - export function VernList(props: VernListProps) { const { t } = useTranslation(); return ( - + <> {t("addWords.selectEntry")} {props.vernacularWords.map((word) => ( @@ -94,6 +77,6 @@ export function VernList(props: VernListProps) { {props.vernacularWords[0].vernacular} - + ); } diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/NewEntry.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/NewEntry.test.tsx index 93037c1fe7..493d787985 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/NewEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/NewEntry.test.tsx @@ -1,9 +1,11 @@ +import { createRef } from "react"; +import { Provider } from "react-redux"; import renderer from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import NewEntry from "components/DataEntry/DataEntryTable/NewEntry/NewEntry"; -import { newSemanticDomain } from "types/semanticDomain"; import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); @@ -11,20 +13,35 @@ jest.mock("@mui/material/Autocomplete", () => "div"); jest.mock("components/Pronunciations/PronunciationsComponent", () => "div"); jest.mock("components/Pronunciations/Recorder"); +const mockStore = configureMockStore()({ treeViewState: { open: false } }); + describe("NewEntry", () => { it("renders without crashing", () => { renderer.act(() => { renderer.create( - + + ()} + // Parent component handles vern suggestion state: + setSelectedDup={jest.fn()} + suggestedVerns={[]} + suggestedDups={[]} + /> + ); }); }); diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx index 99afd4e31b..914e7b7cf1 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx @@ -6,10 +6,8 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import { Word } from "api/models"; -import { - StyledMenuItem, - VernList, -} from "components/DataEntry/DataEntryTable/NewEntry/VernDialog"; +import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; +import { VernList } from "components/DataEntry/DataEntryTable/NewEntry/VernDialog"; import theme from "types/theme"; import { simpleWord, testWordList } from "types/word"; import { defaultWritingSystem } from "types/writingSystem"; diff --git a/src/components/DataEntry/DataEntryTable/tests/DataEntryTable.test.tsx b/src/components/DataEntry/DataEntryTable/tests/DataEntryTable.test.tsx index a9b04e3983..11d25f633f 100644 --- a/src/components/DataEntry/DataEntryTable/tests/DataEntryTable.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/DataEntryTable.test.tsx @@ -1,8 +1,11 @@ +import { Provider } from "react-redux"; import renderer from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import { Gloss, SemanticDomain, Sense, Word } from "api/models"; +import { defaultState } from "components/App/DefaultState"; import DataEntryTable, { WordAccess, addSemanticDomainToSense, @@ -38,17 +41,13 @@ jest.mock("backend", () => ({ jest.mock("backend/localStorage", () => ({ getUserId: () => mockUserId, })); -jest.mock("components/DataEntry/DataEntryTable/NewEntry/SenseDialog"); -jest.mock( - "components/DataEntry/DataEntryTable/NewEntry/VernDialog", - () => "div" -); jest.mock( "components/DataEntry/DataEntryTable/RecentEntry/RecentEntry", () => "div" ); jest.mock("components/Pronunciations/PronunciationsComponent", () => "div"); jest.mock("components/Pronunciations/Recorder"); +jest.mock("utilities/utilities"); jest.spyOn(window, "alert").mockImplementation(() => {}); @@ -57,9 +56,11 @@ let testHandle: renderer.ReactTestInstance; const mockWord = (): Word => simpleWord("mockVern", "mockGloss"); const mockMultiWord = multiSenseWord("vern", ["gloss1", "gloss2"]); -const mockTreeNode = newSemanticDomainTreeNode("semDomId"); +const mockSemDomId = "semDomId"; +const mockTreeNode = newSemanticDomainTreeNode(mockSemDomId); const mockSemDom = semDomFromTreeNode(mockTreeNode); const mockUserId = "mockUserId"; +const mockStore = configureMockStore()(defaultState); const mockCreateWord = jest.fn(); const mockGetFrontierWords = jest.fn(); @@ -85,32 +86,29 @@ beforeEach(() => { const renderTable = async (): Promise => { await renderer.act(async () => { testRenderer = renderer.create( - + + + ); }); }; describe("DataEntryTable", () => { describe("initial render", () => { - beforeEach(async () => { - await renderTable(); - }); + beforeEach(async () => await renderTable()); - it("gets project data and frontier word", () => { - expect(mockGetProject).toBeCalledTimes(1); + it("gets frontier word", () => { expect(mockGetFrontierWords).toBeCalledTimes(1); }); }); describe("exit button", () => { - beforeEach(async () => { - await renderTable(); - }); + beforeEach(async () => await renderTable()); it("hides questions", async () => { expect(mockHideQuestions).not.toBeCalled(); @@ -120,24 +118,21 @@ describe("DataEntryTable", () => { }); it("creates word when new entry has vernacular", async () => { - // Verify that NewEntry is present - const newEntryItems = testRenderer.root.findAllByType(NewEntry); - expect(newEntryItems.length).toBe(1); - // Set the new entry to have useful content - const newEntry = simpleWord("hasVern", ""); - newEntryItems[0].instance.setState({ newEntry }); + expect(mockCreateWord).not.toBeCalled(); + testHandle = testRenderer.root.findByType(NewEntry); + expect(testHandle).not.toBeNull; + // Set newVern but not newGloss. + await renderer.act(async () => testHandle.props.setNewVern("hasVern")); testHandle = testRenderer.root.findByProps({ id: exitButtonId }); await renderer.act(async () => await testHandle.props.onClick()); - expect(mockCreateWord).toBeCalled(); + expect(mockCreateWord).toBeCalledTimes(1); }); it("doesn't create word when new entry has no vernacular", async () => { - // Verify that NewEntry is present - const newEntryItems = testRenderer.root.findAllByType(NewEntry); - expect(newEntryItems.length).toBe(1); - // Set the new entry to have no useful content - const newEntry = simpleWord("", "hasGloss"); - newEntryItems[0].instance.setState({ newEntry }); + testHandle = testRenderer.root.findByType(NewEntry); + expect(testHandle).not.toBeNull; + // Set newGloss but not newVern. + await renderer.act(async () => testHandle.props.setNewGloss("hasGloss")); testHandle = testRenderer.root.findByProps({ id: exitButtonId }); await renderer.act(async () => await testHandle.props.onClick()); expect(mockCreateWord).not.toBeCalled(); @@ -215,56 +210,53 @@ describe("DataEntryTable", () => { }); describe("updateWordWithNewGloss", () => { - beforeEach(async () => { - await renderTable(); - }); + const changeSemDoms = ( + word: Word, + semanticDomains: SemanticDomain[] + ): Word => { + const senses = [...word.senses]; + senses[0] = { ...senses[0], semanticDomains }; + return { ...word, senses }; + }; it("doesn't update word in backend if sense is a duplicate", async () => { - testHandle = testRenderer.root.findByType(DataEntryTable); - mockMultiWord.senses[0].semanticDomains = [ - newSemanticDomain("differentSemDomId"), - newSemanticDomain(testHandle.props.semanticDomain.id), - ]; - + const word = changeSemDoms(mockMultiWord, [ + newSemanticDomain("someSemDomId"), + newSemanticDomain(mockSemDomId), + ]); + mockGetFrontierWords.mockResolvedValue([word]); + await renderTable(); testHandle = testRenderer.root.findByType(NewEntry); - await renderer.act( - async () => - await testHandle.props.updateWordWithNewGloss( - mockMultiWord.id, - firstGlossText(mockMultiWord.senses[0]), - [] - ) - ); + await renderer.act(async () => { + await testHandle.props.setNewGloss(firstGlossText(word.senses[0])); + await testHandle.props.updateWordWithNewGloss(word.id); + }); expect(mockUpdateWord).not.toBeCalled(); }); it("updates word in backend if gloss exists with different semantic domain", async () => { - mockMultiWord.senses[0].semanticDomains = [ - newSemanticDomain("differentSemDomId"), - newSemanticDomain("anotherDifferentSemDomId"), + const word = changeSemDoms(mockMultiWord, [ + newSemanticDomain("someSemDomId"), + newSemanticDomain("anotherSemDomId"), newSemanticDomain("andAThird"), - ]; + ]); + mockGetFrontierWords.mockResolvedValue([word]); + await renderTable(); testHandle = testRenderer.root.findByType(NewEntry); await renderer.act(async () => { - await testHandle.props.updateWordWithNewGloss( - mockMultiWord.id, - firstGlossText(mockMultiWord.senses[0]), - [] - ); + await testHandle.props.setNewGloss(firstGlossText(word.senses[0])); + await testHandle.props.updateWordWithNewGloss(word.id); }); expect(mockUpdateWord).toBeCalledTimes(1); }); it("updates word in backend if gloss doesn't exist", async () => { + await renderTable(); testHandle = testRenderer.root.findByType(NewEntry); - await renderer.act( - async () => - await testHandle.props.updateWordWithNewGloss( - mockMultiWord.id, - "differentGloss", - [] - ) - ); + await renderer.act(async () => { + await testHandle.props.setNewGloss("differentGloss"); + await testHandle.props.updateWordWithNewGloss(mockMultiWord.id); + }); expect(mockUpdateWord).toBeCalledTimes(1); }); });