Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor NewEntry: extract Dialogs from VernWithSug #738

Merged
merged 8 commits into from
Oct 7, 2020
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { TextField } from "@material-ui/core";
import { Autocomplete } from "@material-ui/lab";
import React from "react";
import {
LocalizeContextProps,
Translate,
withLocalize,
} from "react-localize-redux";
import { LocalizeContextProps, withLocalize } from "react-localize-redux";

import SpellChecker from "../../spellChecker";

Expand Down Expand Up @@ -62,7 +58,9 @@ export class GlossWithSuggestions extends React.Component<
{...params}
fullWidth
inputRef={this.props.glossInput}
label={this.props.isNew ? <Translate id="addWords.glosses" /> : ""}
label={
this.props.isNew ? this.props.translate("addWords.gloss") : ""
}
variant={this.props.isNew ? "outlined" : "standard"}
/>
)}
Expand Down
220 changes: 169 additions & 51 deletions src/components/DataEntry/DataEntryTable/NewEntry/NewEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { Grid, Typography } from "@material-ui/core";
import React from "react";
import { Translate } from "react-localize-redux";

import DupFinder, {
DefaultParams,
} from "../../../../goals/MergeDupGoal/DuplicateFinder/DuplicateFinder";
import theme from "../../../../types/theme";
import { SemanticDomain, simpleWord, Word } from "../../../../types/word";
import Pronunciations from "../../../Pronunciations/PronunciationsComponent";
import Recorder from "../../../Pronunciations/Recorder";
import GlossWithSuggestions from "../GlossWithSuggestions/GlossWithSuggestions";
import VernWithSuggestions from "../VernWithSuggestions/VernWithSuggestions";
import SenseDialog from "./SenseDialog";
import VernDialog from "./VernDialog";

interface NewEntryProps {
allVerns: string[];
Expand All @@ -27,10 +32,13 @@ interface NewEntryProps {

interface NewEntryState {
newEntry: Word;
isDupVern: boolean;
wordId?: string;
suggestedVerns: string[];
dupVernWords: Word[];
activeGloss: string;
audioFileURLs: string[];
vernOpen: boolean;
senseOpen: boolean;
selectedWord?: Word;
}

function focusInput(inputRef: React.RefObject<HTMLDivElement>) {
Expand All @@ -47,13 +55,23 @@ export default class NewEntry extends React.Component<
NewEntryProps,
NewEntryState
> {
readonly maxSuggestions = 5;
readonly maxLevDistance = 3; // The default 5 allows too much distance
suggestionFinder: DupFinder = new DupFinder({
...DefaultParams,
maxScore: this.maxLevDistance,
});

constructor(props: NewEntryProps) {
super(props);
this.state = {
newEntry: { ...simpleWord("", ""), id: "" },
activeGloss: "",
audioFileURLs: [],
isDupVern: false,
suggestedVerns: [],
dupVernWords: [],
vernOpen: false,
senseOpen: false,
};
this.vernInput = React.createRef<HTMLDivElement>();
this.glossInput = React.createRef<HTMLDivElement>();
Expand Down Expand Up @@ -92,36 +110,42 @@ export default class NewEntry extends React.Component<
}));
}

updateVernField(newValue: string): Word[] {
let dupVernWords: Word[] = [];
let isDupVern: boolean = false;
if (newValue) {
dupVernWords = this.props.allWords.filter(
(word: Word) =>
word.vernacular === newValue &&
!this.props.defunctWordIds.includes(word.id)
// Weed out any words that are already being edited
);
isDupVern = dupVernWords.length > 0;
updateVernField(newValue: string, openDialog?: boolean) {
const stateUpdates: Partial<NewEntryState> = {};
if (newValue !== this.state.newEntry.vernacular) {
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.setState((prevState) => ({
isDupVern,
newEntry: { ...prevState.newEntry, vernacular: newValue },
}));
return dupVernWords;
}

updateWordId(wordId?: string) {
this.setState({ wordId });
this.setState(stateUpdates as NewEntryState, () => {
if (
openDialog &&
this.state.dupVernWords.length &&
!this.state.selectedWord
) {
this.setState({ vernOpen: true });
}
});
}

resetState() {
this.setState({
newEntry: { ...simpleWord("", ""), id: "" },
activeGloss: "",
audioFileURLs: [],
isDupVern: false,
wordId: undefined,
suggestedVerns: [],
dupVernWords: [],
selectedWord: undefined,
});
this.focusVernInput();
}
Expand All @@ -143,34 +167,37 @@ export default class NewEntry extends React.Component<

updateWordAndReset() {
this.props.updateWordWithNewGloss(
this.state.wordId!,
this.state.selectedWord!.id,
this.state.activeGloss,
this.state.audioFileURLs
);
this.resetState();
}

addOrUpdateWord() {
if (!this.state.isDupVern || this.state.wordId === "") {
// Either a new Vern is typed, or user has selected new entry for this duplicate vern
this.addNewWordAndReset();
} else if (this.state.wordId === undefined && this.state.isDupVern) {
// Duplicate vern and the user hasn't made a selection
// Change focus away from vern to trigger vern's onBlur
this.focusGlossInput();
if (this.state.dupVernWords.length) {
// Duplicate vern ...
if (!this.state.selectedWord) {
// ... and user hasn't made a selection
this.setState({ vernOpen: true });
} else if (this.state.selectedWord.id) {
// ... and user has selected an entry to modify
this.updateWordAndReset();
} else {
// ... and user has selected new entry
this.addNewWordAndReset();
}
} else {
// Duplicate vern and the user has selected an entry to modify,
// so wordId is defined and non-empty
this.updateWordAndReset();
// New Vern is typed
this.addNewWordAndReset();
}
}

async handleEnterAndTab(e: React.KeyboardEvent) {
if (e.key === "Enter") {
handleEnterAndTab(e: React.KeyboardEvent) {
if (!this.state.vernOpen && e.key === "Enter") {
if (this.state.newEntry.vernacular) {
if (this.state.activeGloss) {
await this.addOrUpdateWord();
this.resetState();
this.addOrUpdateWord();
this.focusVernInput();
} else {
this.focusGlossInput();
Expand All @@ -181,6 +208,83 @@ export default class NewEntry extends React.Component<
}
}

handleCloseVernDialog(selectedWordId?: string) {
let selectedWord: Word | undefined;
let senseOpen = false;
if (selectedWordId === "") {
selectedWord = {
...simpleWord(this.state.newEntry.vernacular, ""),
id: "",
};
} else if (selectedWordId) {
selectedWord = this.state.dupVernWords.find(
(word: Word) => word.id === selectedWordId
);
senseOpen = true;
}
this.setState({ selectedWord, senseOpen, vernOpen: false });
}

handleCloseSenseDialog(senseIndex?: number) {
if (senseIndex === undefined) {
this.setState({ selectedWord: undefined, vernOpen: true });
} else if (senseIndex >= 0) {
this.setState((prevState) => ({
activeGloss: prevState.selectedWord!.senses[senseIndex].glosses[0].def,
}));
} // Otherwise, senseIndex===-1, which 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)
let scoredStartsWith: [string, number][] = [];
let startsWith = this.props.allVerns.filter((vern: string) =>
vern.startsWith(vernacular)
);
for (const v of startsWith) {
scoredStartsWith.push([v, v.length]);
}
let 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);
}
return keepers;
}

updateSuggestedVerns(value?: string | null) {
let suggestedVerns: string[] = [];
if (value) {
suggestedVerns = [...this.autoCompleteCandidates(value)];
if (suggestedVerns.length < this.maxSuggestions) {
const viableVerns: string[] = this.props.allVerns.filter(
(vern: string) =>
this.suggestionFinder.getLevenshteinDistance(vern, value) <
this.suggestionFinder.maxScore
);
const sortedVerns: string[] = viableVerns.sort(
(a: string, b: string) =>
this.suggestionFinder.getLevenshteinDistance(a, value) -
this.suggestionFinder.getLevenshteinDistance(b, value)
);
let candidate: string;
while (
suggestedVerns.length < this.maxSuggestions &&
sortedVerns.length
) {
candidate = sortedVerns.shift()!;
if (!suggestedVerns.includes(candidate))
suggestedVerns.push(candidate);
}
}
}
this.setState({ suggestedVerns });
}

render() {
return (
<Grid item xs={12}>
Expand All @@ -200,21 +304,35 @@ export default class NewEntry extends React.Component<
isNew={true}
vernacular={this.state.newEntry.vernacular}
vernInput={this.vernInput}
updateVernField={(newValue: string) => {
this.props.setIsReadyState(newValue.trim().length > 0);
return this.updateVernField(newValue);
updateVernField={(newValue: string, openDialog?: boolean) => {
this.updateVernField(newValue, openDialog);
}}
onBlur={() => {
this.updateVernField(this.state.newEntry.vernacular, true);
}}
updateWordId={(wordId?: string) => this.updateWordId(wordId)}
selectedWordId={this.state.wordId}
allVerns={this.props.allVerns}
suggestedVerns={this.state.suggestedVerns}
handleEnterAndTab={(e: React.KeyboardEvent) =>
this.handleEnterAndTab(e)
}
setActiveGloss={(newGloss: string) =>
this.setState({ activeGloss: newGloss })
/>
<VernDialog
open={this.state.vernOpen}
handleClose={(selectedWordId?: string) =>
this.handleCloseVernDialog(selectedWordId)
}
vernacularWords={this.state.dupVernWords}
analysisLang={this.props.analysisLang}
/>
{this.state.selectedWord && (
<SenseDialog
selectedWord={this.state.selectedWord}
open={this.state.senseOpen}
handleClose={(senseIndex?: number) =>
this.handleCloseSenseDialog(senseIndex)
}
analysisLang={this.props.analysisLang}
/>
)}
</Grid>
<Grid item xs={12}>
<Typography variant="caption">
Expand Down Expand Up @@ -257,13 +375,13 @@ export default class NewEntry extends React.Component<
wordId={""}
pronunciationFiles={this.state.audioFileURLs}
recorder={this.props.recorder}
deleteAudio={(_wordId: string, fileName: string) => {
deleteAudio={(_, fileName: string) => {
this.removeAudio(fileName);
}}
uploadAudio={(_wordId: string, audioFile: File) => {
uploadAudio={(_, audioFile: File) => {
this.addAudio(audioFile);
}}
getAudioUrl={(_wordId: string, fileName: string) => fileName}
getAudioUrl={(_, fileName: string) => fileName}
/>
</Grid>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import {
withLocalize,
} from "react-localize-redux";

import theme from "../../../../../types/theme";
import { Sense, Word } from "../../../../../types/word";
import DomainCell from "../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell";
import { parseWord } from "../../../../../goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes";
import theme from "../../../../types/theme";
import { Sense, Word } from "../../../../types/word";
import DomainCell from "../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell";
import { parseWord } from "../../../../goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes";

function SenseDialog(
props: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
withLocalize,
} from "react-localize-redux";

import theme from "../../../../../types/theme";
import { Word } from "../../../../../types/word";
import DomainCell from "../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell";
import SenseCell from "../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell";
import { parseWord } from "../../../../../goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes";
import theme from "../../../../types/theme";
import { Word } from "../../../../types/word";
import DomainCell from "../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell";
import SenseCell from "../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell";
import { parseWord } from "../../../../goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes";

export function VernDialog(
props: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import React from "react";
import { Provider } from "react-redux";
import renderer, { ReactTestInstance } from "react-test-renderer";
import configureMockStore from "redux-mock-store";

import { VernList, StyledMenuItem } from "../VernDialog";
import { simpleWord, Word, testWordList } from "../../../../../../types/word";
import { Provider } from "react-redux";
import { simpleWord, Word, testWordList } from "../../../../../types/word";

jest.mock(
"../../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell"
"../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DomainCell"
);
jest.mock(
"../../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell"
"../../../../../goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell"
);
const createMockStore = configureMockStore([]);
const mockStore = createMockStore({});
Expand Down
Loading