diff --git a/Backend/Models/User.cs b/Backend/Models/User.cs index 85bbdd6de7..d6407b1416 100644 --- a/Backend/Models/User.cs +++ b/Backend/Models/User.cs @@ -66,10 +66,14 @@ public class User [BsonElement("username")] public string Username { get; set; } - /// Not implemented in frontend. [BsonElement("uiLang")] public string UILang { get; set; } + [Required] + [BsonElement("glossSuggestion")] + [BsonRepresentation(BsonType.String)] + public AutocompleteSetting GlossSuggestion { get; set; } + [Required] [BsonElement("token")] public string Token { get; set; } @@ -94,6 +98,7 @@ public User() Password = ""; Username = ""; UILang = ""; + GlossSuggestion = AutocompleteSetting.On; Token = ""; IsAdmin = false; WorkedProjects = new(); @@ -115,6 +120,7 @@ public User Clone() Password = Password, Username = Username, UILang = UILang, + GlossSuggestion = GlossSuggestion, Token = Token, IsAdmin = IsAdmin, WorkedProjects = WorkedProjects.ToDictionary(kv => kv.Key, kv => kv.Value), @@ -136,6 +142,7 @@ public bool ContentEquals(User other) other.Password.Equals(Password, StringComparison.Ordinal) && other.Username.Equals(Username, StringComparison.Ordinal) && other.UILang.Equals(UILang, StringComparison.Ordinal) && + other.GlossSuggestion.Equals(GlossSuggestion) && other.Token.Equals(Token, StringComparison.Ordinal) && other.IsAdmin == IsAdmin && @@ -172,6 +179,7 @@ public override int GetHashCode() hash.Add(Password); hash.Add(Username); hash.Add(UILang); + hash.Add(GlossSuggestion); hash.Add(Token); hash.Add(IsAdmin); return hash.ToHashCode(); diff --git a/Backend/Repositories/UserRepository.cs b/Backend/Repositories/UserRepository.cs index e576ea2966..0ab2f006bb 100644 --- a/Backend/Repositories/UserRepository.cs +++ b/Backend/Repositories/UserRepository.cs @@ -196,7 +196,8 @@ public async Task Update(string userId, User user, bool updateIs .Set(x => x.ProjectRoles, user.ProjectRoles) .Set(x => x.Agreement, user.Agreement) .Set(x => x.Username, user.Username) - .Set(x => x.UILang, user.UILang); + .Set(x => x.UILang, user.UILang) + .Set(x => x.GlossSuggestion, user.GlossSuggestion); // If .Avatar or .Token has been set to null or "", // this prevents it from being erased in the database diff --git a/Backend/Services/PermissionService.cs b/Backend/Services/PermissionService.cs index 40bc5db85a..e1dabaa1d8 100644 --- a/Backend/Services/PermissionService.cs +++ b/Backend/Services/PermissionService.cs @@ -170,7 +170,7 @@ public string GetUserId(HttpContext request) /// Creates a JWT token for the given user. public async Task MakeJwt(User user) { - const int hoursUntilExpires = 4; + const int hoursUntilExpires = 12; var tokenHandler = new JwtSecurityTokenHandler(); var secretKey = Environment.GetEnvironmentVariable("COMBINE_JWT_SECRET_KEY")!; var key = Encoding.ASCII.GetBytes(secretKey); diff --git a/README.md b/README.md index 9b93d65325..4785b4da04 100644 --- a/README.md +++ b/README.md @@ -1167,8 +1167,9 @@ The process for configuring and deploying _TheCombine_ for production targets is - [JS](https://www.w3schools.com/js/default.asp) - [TS](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) - [Our style guide](docs/style_guide/ts_style_guide.md) -- [React](https://reactjs.org/) -- [React Hooks](https://reactjs.org/docs/hooks-intro.html) +- [React](https://react.dev/learn) +- [React Hooks](https://react.dev/reference/react/hooks) +- [MUI](https://mui.com/material-ui/getting-started/) (styled/themed components) - [Redux concepts](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow) - [Redux tutorials](https://redux.js.org/tutorials/typescript-quick-start) - [React-i18next](https://react.i18next.com/) (text localization) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 4b4b3fdebe..373dda5f8f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -30,7 +30,7 @@ "domain": "Domain", "domainTitle": "Domain: {{ val1 }} ({{ val2 }})", "glosses": "Glosses", - "pressEnter": "Press enter to save word", + "pressEnter": "Press Enter to save word", "vernacular": "Vernacular" }, "appBar": { @@ -121,11 +121,14 @@ "banners": { "title": "Banners", "loginBanner": "Login Banner", - "announcementBanner": "Announcement Banner" + "announcementBanner": "Announcement Banner", + "bannerCloseButton": "Close banner" } }, "userSettings": { "contact": "Contact info", + "glossSuggestion": "Gloss spelling suggestions", + "glossSuggestionHint": "In Data Entry, give spelling suggestions for the Gloss being typed.", "phone": "Phone number", "uiLanguage": "User-interface language", "uiLanguageDefault": "(Default to browser language)", diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index d67b183776..054e5ad865 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -29,7 +29,7 @@ "domain": "Domínio", "domainTitle": "Domínio: {{ val1 }} ({{ val2 }})", "glosses": "Glosas", - "pressEnter": "Pressione enter para salvar a palavra", + "pressEnter": "Pressione Enter para salvar a palavra", "vernacular": "Vernáculo" }, "appBar": { diff --git a/public/manifest.json b/public/manifest.json index 0af41c8255..0d803acdbe 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,6 @@ { "short_name": "The Combine", - "name": "The Word Combine", + "name": "The Combine", "icons": [ { "src": "favicon.ico", diff --git a/src/api/models/user.ts b/src/api/models/user.ts index a563dd8b9c..fef3669aef 100644 --- a/src/api/models/user.ts +++ b/src/api/models/user.ts @@ -12,6 +12,8 @@ * Do not edit the class manually. */ +import { AutocompleteSetting } from "./autocomplete-setting"; + /** * * @export @@ -108,4 +110,10 @@ export interface User { * @memberof User */ isAdmin: boolean; + /** + * + * @type {AutocompleteSetting} + * @memberof User + */ + glossSuggestion: AutocompleteSetting; } diff --git a/src/components/AnnouncementBanner/AnnouncementBanner.tsx b/src/components/AnnouncementBanner/index.tsx similarity index 80% rename from src/components/AnnouncementBanner/AnnouncementBanner.tsx rename to src/components/AnnouncementBanner/index.tsx index 769199275c..495ddc2c9f 100644 --- a/src/components/AnnouncementBanner/AnnouncementBanner.tsx +++ b/src/components/AnnouncementBanner/index.tsx @@ -8,6 +8,7 @@ import { useEffect, useState, } from "react"; +import { useTranslation } from "react-i18next"; import { BannerType } from "api/models"; import { getBannerText } from "backend"; @@ -18,10 +19,20 @@ import { type StoreState } from "rootRedux/types"; import { Path } from "types/path"; import theme, { themeColors } from "types/theme"; +enum AnnouncementBannerId { + ButtonClose = "announcement-banner-close-button", +} + +export enum AnnouncementBannerTextId { + ButtonClose = "siteSettings.banners.bannerCloseButton", +} + export default function AnnouncementBanner(): ReactElement { const [banner, setBanner] = useState(""); const [margins, setMargins] = useState({}); + const { t } = useTranslation(); + // Adjust the margins depending on whether there is an AppBar. const loc = useAppSelector( (state: StoreState) => state.analyticsState.currentPage @@ -48,7 +59,12 @@ export default function AnnouncementBanner(): ReactElement { return banner ? ( - + diff --git a/src/components/AnnouncementBanner/tests/index.test.tsx b/src/components/AnnouncementBanner/tests/index.test.tsx new file mode 100644 index 0000000000..dab689f225 --- /dev/null +++ b/src/components/AnnouncementBanner/tests/index.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { act } from "react"; +import { Provider } from "react-redux"; +import createMockStore from "redux-mock-store"; + +import AnnouncementBanner, { + AnnouncementBannerTextId, +} from "components/AnnouncementBanner"; +import { defaultState } from "rootRedux/types"; + +jest.mock("backend", () => ({ + getBannerText: () => mockGetBannerText(), +})); + +const mockBannerText = "I'm a banner!"; +const mockGetBannerText = jest.fn(); +const mockStore = createMockStore()(defaultState); + +const renderAnnouncementBanner = async (bannerText?: string): Promise => { + mockGetBannerText.mockResolvedValue(bannerText ?? ""); + await act(async () => { + render( + + + + ); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("AnnouncementBanner", () => { + it("doesn't load if no banner text", async () => { + await renderAnnouncementBanner(); + + // Confirm no banner by the absence of its close button + expect( + screen.queryByLabelText(AnnouncementBannerTextId.ButtonClose) + ).toBeNull(); + }); + + it("loads banner with text", async () => { + await renderAnnouncementBanner(mockBannerText); + + // Confirm open banner by the presence of its close button and text + expect( + screen.queryByLabelText(AnnouncementBannerTextId.ButtonClose) + ).not.toBeNull(); + expect(screen.queryByText(mockBannerText)).not.toBeNull(); + }); + + it("closes when button is clicked", async () => { + // Setup + const agent = userEvent.setup(); + await renderAnnouncementBanner(mockBannerText); + expect(screen.queryByText(mockBannerText)).not.toBeNull(); + + // Click close button + const closeButton = screen.getByLabelText( + AnnouncementBannerTextId.ButtonClose + ); + await agent.click(closeButton); + + // Confirm closed + expect( + screen.queryByLabelText(AnnouncementBannerTextId.ButtonClose) + ).toBeNull(); + expect(screen.queryByText(mockBannerText)).toBeNull(); + }); +}); diff --git a/src/components/App/DefaultState.ts b/src/components/App/DefaultState.ts deleted file mode 100644 index e872be5aed..0000000000 --- a/src/components/App/DefaultState.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defaultState as loginState } from "components/Login/Redux/LoginReduxTypes"; -import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; -import { defaultState as exportProjectState } from "components/ProjectExport/Redux/ExportProjectReduxTypes"; -import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { defaultState as treeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { defaultState as mergeDuplicateGoal } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; -import { defaultState as goalsState } from "goals/Redux/GoalReduxTypes"; -import { defaultState as analyticsState } from "types/Redux/analyticsReduxTypes"; - -export const defaultState = { - //login and signup - loginState: { ...loginState }, - - //project - currentProjectState: { ...currentProjectState }, - exportProjectState: { ...exportProjectState }, - - //data entry and review entries goal - treeViewState: { ...treeViewState }, - pronunciationsState: { ...pronunciationsState }, - - //goal timeline and current goal - goalsState: { ...goalsState }, - - //merge duplicates goal and review deferred duplicates goal - mergeDuplicateGoal: { ...mergeDuplicateGoal }, - - //character inventory goal - characterInventoryState: { ...characterInventoryState }, - - //analytics state - analyticsState: { ...analyticsState }, -}; diff --git a/src/components/App/component.tsx b/src/components/App/index.tsx similarity index 86% rename from src/components/App/component.tsx rename to src/components/App/index.tsx index 12c2152216..d66ae6d3d1 100644 --- a/src/components/App/component.tsx +++ b/src/components/App/index.tsx @@ -1,7 +1,7 @@ import { ReactElement, Suspense } from "react"; import { RouterProvider } from "react-router-dom"; -import AnnouncementBanner from "components/AnnouncementBanner/AnnouncementBanner"; +import AnnouncementBanner from "components/AnnouncementBanner"; import UpperRightToastContainer from "components/Toast/UpperRightToastContainer"; import router from "router/browserRouter"; diff --git a/src/components/App/App.test.tsx b/src/components/App/tests/index.test.tsx similarity index 72% rename from src/components/App/App.test.tsx rename to src/components/App/tests/index.test.tsx index 52b24c6cca..de656201c8 100644 --- a/src/components/App/App.test.tsx +++ b/src/components/App/tests/index.test.tsx @@ -1,16 +1,15 @@ +import { render } from "@testing-library/react"; import "jest-canvas-mock"; +import { act } from "react"; import { Provider } from "react-redux"; -import { act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import thunk from "redux-thunk"; -import { defaultState } from "components/App/DefaultState"; -import App from "components/App/component"; +import App from "components/App"; +import { defaultState } from "rootRedux/types"; jest.mock("react-router-dom"); -jest.mock("components/AnnouncementBanner/AnnouncementBanner", () => "div"); - const createMockStore = configureMockStore([thunk]); const mockStore = createMockStore(defaultState); @@ -22,7 +21,7 @@ global.analytics = { track: jest.fn() } as any; describe("App", () => { it("renders without crashing", async () => { await act(async () => { - create( + render( diff --git a/src/components/AppBar/tests/AppBarComponent.test.tsx b/src/components/AppBar/tests/AppBarComponent.test.tsx index 6c100b69e6..a3c78dee6e 100644 --- a/src/components/AppBar/tests/AppBarComponent.test.tsx +++ b/src/components/AppBar/tests/AppBarComponent.test.tsx @@ -3,8 +3,8 @@ import { MemoryRouter } from "react-router-dom"; import { act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import AppBar from "components/AppBar/AppBarComponent"; +import { defaultState } from "rootRedux/types"; jest.mock("backend", () => ({ isSiteAdmin: () => mockIsSiteAdmin(), diff --git a/src/components/Buttons/IconButtonWithTooltip.tsx b/src/components/Buttons/IconButtonWithTooltip.tsx index f0878bb37a..0809a68a72 100644 --- a/src/components/Buttons/IconButtonWithTooltip.tsx +++ b/src/components/Buttons/IconButtonWithTooltip.tsx @@ -6,6 +6,7 @@ interface IconButtonWithTooltipProps { disabled?: boolean; icon: ReactElement; text?: ReactNode; + /** `textId` will only be used if `text` is null or undefined. */ textId?: string; size?: "large" | "medium" | "small"; onClick?: MouseEventHandler; diff --git a/src/components/Buttons/NoteButton.tsx b/src/components/Buttons/NoteButton.tsx index 8492e48f77..dc1307336a 100644 --- a/src/components/Buttons/NoteButton.tsx +++ b/src/components/Buttons/NoteButton.tsx @@ -7,6 +7,8 @@ import { EditTextDialog } from "components/Dialogs"; interface NoteButtonProps { buttonId?: string; disabled?: boolean; + /** If `noteText` is empty and `updateNote` defined, + * the button will have default add-note hover text. */ noteText: string; updateNote?: (newText: string) => void | Promise; } @@ -34,8 +36,8 @@ export default function NoteButton(props: NoteButtonProps): ReactElement { onClick={props.updateNote ? () => setNoteOpen(true) : undefined} side="top" size="small" - text={props.noteText} - textId="addWords.addNote" + text={props.noteText || undefined} + textId={props.updateNote ? "addWords.addNote" : undefined} /> { if (id !== undefined) { setSelectedDup(id); + } else { + // User closed the dialog without choosing a duplicate entry or new entry. + // Highlight-select the typed vernacular for easy deletion. + vernInput.current?.setSelectionRange(0, vernInput.current.value.length); } setVernOpen(false); }; @@ -332,7 +336,7 @@ function EnterGrid(): ReactElement { const { t } = useTranslation(); return ( - {t("addWords.pressEnter")} + {t("addWords.pressEnter")} ); } diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index bf0a0b5da4..ce9f8325cc 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -26,7 +26,7 @@ import { Word, } from "api/models"; import * as backend from "backend"; -import { getUserId } from "backend/localStorage"; +import { getCurrentUser, getUserId } from "backend/localStorage"; import NewEntry from "components/DataEntry/DataEntryTable/NewEntry"; import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry"; import { filterWordsWithSenses } from "components/DataEntry/utilities"; @@ -69,8 +69,9 @@ interface SenseSwitch { } export interface WordAccess { - word: Word; + isNew: boolean; senseGuid: string; + word: Word; } enum DefunctStatus { @@ -270,7 +271,11 @@ export default function DataEntryTable( const newVernInput = useRef(null); const spellChecker = useContext(SpellCheckerContext); useEffect(() => { - spellChecker.updateLang(analysisLang.bcp47); + spellChecker.updateLang( + getCurrentUser()?.glossSuggestion === AutocompleteSetting.Off + ? undefined + : analysisLang.bcp47 + ); }, [analysisLang.bcp47, spellChecker]); const { t } = useTranslation(); @@ -335,7 +340,7 @@ export default function DataEntryTable( const recentWords = [...prevState.recentWords]; word.senses.forEach((s) => { if (s.semanticDomains.some((dom) => dom.id === domId)) { - recentWords.push({ word, senseGuid: s.guid }); + recentWords.push({ isNew: false, senseGuid: s.guid, word }); } }); return { ...prevState, recentWords }; @@ -358,6 +363,7 @@ export default function DataEntryTable( if (replaceIndex > -1) { deleteCount = 1; insertIndex = replaceIndex; + wordAccess.isNew = recentWords[replaceIndex].isNew; } if (insertIndex > -1 && insertIndex < recentWords.length) { @@ -392,7 +398,7 @@ export default function DataEntryTable( const replaceInDisplay = (oldId: string, word: Word): void => { setState((prevState) => { const recentWords = prevState.recentWords.map((a) => - a.word.id === oldId ? { word, senseGuid: a.senseGuid } : a + a.word.id === oldId ? { ...a, word } : a ); return { ...prevState, isFetchingFrontier: true, recentWords }; }); @@ -738,7 +744,10 @@ export default function DataEntryTable( if (wordId !== word.id) { word = await backend.getWord(wordId); } - addToDisplay({ word, senseGuid: word.senses[0].guid }, insertIndex); + addToDisplay( + { isNew: true, senseGuid: word.senses[0].guid, word }, + insertIndex + ); }, [addAudiosToBackend, addDuplicateWord] ); @@ -754,7 +763,7 @@ export default function DataEntryTable( const wordId = await addAudiosToBackend(word.id, audio); word = await backend.getWord(wordId); } - addToDisplay({ word, senseGuid }); + addToDisplay({ isNew: false, senseGuid, word }); }; /** Reset the entry table. If there is an un-submitted word then submit it. */ @@ -880,7 +889,7 @@ export default function DataEntryTable( /** Retract a recent entry. */ const undoRecentEntry = useCallback( async (eIndex: number): Promise => { - const { word, senseGuid } = state.recentWords[eIndex]; + const { isNew, senseGuid, word } = state.recentWords[eIndex]; const sIndex = word.senses.findIndex((s) => s.guid === senseGuid); if (sIndex === -1) { throw new Error("Entry does not have specified sense."); @@ -890,8 +899,9 @@ export default function DataEntryTable( const senses = [...word.senses]; const oldSense = senses[sIndex]; const oldDoms = oldSense.semanticDomains; - if (oldDoms.length > 1) { - // If there is more than one semantic domain in this sense, only remove the domain. + if (oldDoms.length > 1 || !isNew) { + // If there is more than one domain in this sense or the entry isn't new, + // only remove the domain. const doms = oldDoms.filter((d) => d.id !== props.semanticDomain.id); const newSense: Sense = { ...oldSense, semanticDomains: doms }; senses.splice(sIndex, 1, newSense); @@ -901,7 +911,7 @@ export default function DataEntryTable( senses.splice(sIndex, 1); await updateWordInBackend({ ...word, senses }); } else { - // Since this is the only sense, delete the word. + // Since this is the only sense in a new word, delete the word. await backend.deleteFrontierWord(word.id); } }, @@ -1003,6 +1013,14 @@ export default function DataEntryTable( [state.recentWords, updateWordInBackend] ); + const isNewEntryInProgress = + state.newAudio.length || + state.newGloss.trim() || + state.newNote.trim() || + state.newVern.trim(); + const highlightExitButton = + state.recentWords.length > 0 && !isNewEntryInProgress; + return (
) => e?.preventDefault()}> @@ -1108,7 +1126,7 @@ export default function DataEntryTable( id={exitButtonId} type="submit" variant="contained" - color={state.newVern.trim() ? "primary" : "secondary"} + color={highlightExitButton ? "primary" : "secondary"} style={{ marginTop: theme.spacing(2) }} endIcon={} tabIndex={-1} diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 1732ba67b3..c125693b58 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -9,7 +9,6 @@ import { import configureMockStore from "redux-mock-store"; import { Word } from "api/models"; -import { defaultState } from "components/App/DefaultState"; import { NoteButton } from "components/Buttons"; import { DeleteEntry, @@ -21,6 +20,7 @@ import { EditTextDialog } from "components/Dialogs"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import { defaultState } from "rootRedux/types"; import theme from "types/theme"; import { newPronunciation, simpleWord } from "types/word"; import { newWritingSystem } from "types/writingSystem"; diff --git a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx index d733021b16..030ffb887e 100644 --- a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx @@ -9,7 +9,6 @@ import { import configureMockStore from "redux-mock-store"; import { Gloss, SemanticDomain, Sense, Word } from "api/models"; -import { defaultState } from "components/App/DefaultState"; import DataEntryTable, { WordAccess, addSemanticDomainToSense, @@ -20,12 +19,14 @@ import DataEntryTable, { updateEntryGloss, } from "components/DataEntry/DataEntryTable"; import NewEntry from "components/DataEntry/DataEntryTable/NewEntry"; +import { defaultState } from "rootRedux/types"; import { newProject } from "types/project"; import { newSemanticDomain, newSemanticDomainTreeNode, semDomFromTreeNode, } from "types/semanticDomain"; +import { newUser } from "types/user"; import { multiSenseWord, newGloss, @@ -53,6 +54,7 @@ jest.mock("backend", () => ({ updateWord: (...args: any[]) => mockUpdateWord(...args), })); jest.mock("backend/localStorage", () => ({ + getCurrentUser: () => mockUser, getUserId: () => mockUserId, })); jest.mock("components/DataEntry/DataEntryTable/NewEntry/SenseDialog"); @@ -78,6 +80,7 @@ const mockMultiWord = multiSenseWord("vern", ["gloss1", "gloss2"]); const mockSemDomId = "semDomId"; const mockTreeNode = newSemanticDomainTreeNode(mockSemDomId); const mockSemDom = semDomFromTreeNode(mockTreeNode); +const mockUser = newUser(); const mockUserId = "mockUserId"; const mockStore = configureMockStore()(defaultState); @@ -245,7 +248,11 @@ describe("DataEntryTable", () => { describe("updateEntryGloss", () => { it("throws error when entry doesn't have sense with specified guid", () => { - const entry: WordAccess = { word: newWord(), senseGuid: "gibberish" }; + const entry: WordAccess = { + isNew: true, + senseGuid: "gibberish", + word: newWord(), + }; expect(() => updateEntryGloss(entry, "def", "semDomId", "en")).toThrow(); }); @@ -253,7 +260,11 @@ describe("DataEntryTable", () => { const senseIndex = 1; const sense = mockMultiWord.senses[senseIndex]; sense.semanticDomains = [mockSemDom]; - const entry: WordAccess = { word: mockMultiWord, senseGuid: sense.guid }; + const entry: WordAccess = { + isNew: false, + senseGuid: sense.guid, + word: mockMultiWord, + }; const def = "newGlossDef"; const expectedGloss: Gloss = { ...sense.glosses[0], def }; @@ -271,7 +282,11 @@ describe("DataEntryTable", () => { const targetGloss = newGloss("target language", "tl"); sense.glosses = [...sense.glosses, targetGloss]; sense.semanticDomains = [mockSemDom]; - const entry: WordAccess = { word: mockMultiWord, senseGuid: sense.guid }; + const entry: WordAccess = { + isNew: false, + senseGuid: sense.guid, + word: mockMultiWord, + }; const def = "newGlossDef"; const expectedGloss: Gloss = { ...targetGloss, def }; @@ -291,7 +306,7 @@ describe("DataEntryTable", () => { const sense = word.senses[0]; const otherDomain: SemanticDomain = { ...mockSemDom, id: "otherId" }; sense.semanticDomains = [otherDomain, mockSemDom]; - const entry: WordAccess = { word, senseGuid: sense.guid }; + const entry: WordAccess = { isNew: false, senseGuid: sense.guid, word }; const def = "newGlossDef"; const oldSense: Sense = { ...sense, semanticDomains: [otherDomain] }; diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx index 0890475e08..b321627221 100644 --- a/src/components/Project/tests/ProjectActions.test.tsx +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -1,7 +1,4 @@ -import { type PreloadedState } from "redux"; - import { type Project, type Speaker } from "api/models"; -import { defaultState } from "components/App/DefaultState"; import { asyncRefreshProjectUsers, asyncSetNewCurrentProject, @@ -9,7 +6,8 @@ import { clearCurrentProject, } from "components/Project/ProjectActions"; import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; -import { type RootState, setupStore } from "rootRedux/store"; +import { setupStore } from "rootRedux/store"; +import { persistedDefaultState } from "rootRedux/testTypes"; import { newProject } from "types/project"; import { newUser } from "types/user"; @@ -26,12 +24,6 @@ const mockGetAllSemDomNames = jest.fn(); const mockUpdateProject = jest.fn(); const mockProjId = "project-id"; -// Preloaded values for store when testing -const persistedDefaultState: PreloadedState = { - ...defaultState, - _persist: { version: 1, rehydrated: false }, -}; - beforeEach(() => { jest.resetAllMocks(); }); diff --git a/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx b/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx index 0e03482671..d664a23f6d 100644 --- a/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx +++ b/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx @@ -1,13 +1,11 @@ -import { PreloadedState } from "redux"; - -import { defaultState } from "components/App/DefaultState"; import { asyncDownloadExport, asyncExportProject, asyncResetExport, } from "components/ProjectExport/Redux/ExportProjectActions"; import { ExportStatus } from "components/ProjectExport/Redux/ExportProjectReduxTypes"; -import { RootState, setupStore } from "rootRedux/store"; +import { setupStore } from "rootRedux/store"; +import { persistedDefaultState } from "rootRedux/testTypes"; jest.mock("backend", () => ({ deleteLift: jest.fn, @@ -20,12 +18,6 @@ const mockDownloadList = jest.fn(); const mockExportLift = jest.fn(); const mockProjId = "project-id"; -// Preloaded values for store when testing -const persistedDefaultState: PreloadedState = { - ...defaultState, - _persist: { version: 1, rehydrated: false }, -}; - describe("ExportProjectActions", () => { describe("asyncDownloadExport", () => { it("correctly affects state on success", async () => { diff --git a/src/components/ProjectSettings/ProjectAutocomplete.tsx b/src/components/ProjectSettings/ProjectAutocomplete.tsx index 2f6dce3259..1c0f4c7402 100644 --- a/src/components/ProjectSettings/ProjectAutocomplete.tsx +++ b/src/components/ProjectSettings/ProjectAutocomplete.tsx @@ -40,7 +40,7 @@ export default function ProjectAutocomplete( title={t("projectSettings.autocomplete.hint")} placement={document.body.dir === "rtl" ? "left" : "right"} > - + diff --git a/src/components/Pronunciations/tests/AudioPlayer.test.tsx b/src/components/Pronunciations/tests/AudioPlayer.test.tsx index acd768ebe3..d25bdb6d71 100644 --- a/src/components/Pronunciations/tests/AudioPlayer.test.tsx +++ b/src/components/Pronunciations/tests/AudioPlayer.test.tsx @@ -3,14 +3,13 @@ import { Provider } from "react-redux"; import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import AudioPlayer, { longPressDelay, playButtonId, playMenuId, } from "components/Pronunciations/AudioPlayer"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { type StoreState } from "rootRedux/types"; +import { type StoreState, defaultState } from "rootRedux/types"; import { newPronunciation } from "types/word"; // Mock out Menu to avoid issues with setting its anchor. diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index b9653d69fd..92b0394012 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -3,11 +3,10 @@ import { Provider } from "react-redux"; import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import { recordIconId } from "components/Pronunciations/RecorderIcon"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { type StoreState } from "rootRedux/types"; +import { type StoreState, defaultState } from "rootRedux/types"; import theme, { themeColors } from "types/theme"; let testRenderer: ReactTestRenderer; diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx index 5b9460e2b2..e4e7d4ec0b 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -3,10 +3,10 @@ import { Provider } from "react-redux"; import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import { defaultState } from "rootRedux/types"; import theme from "types/theme"; import { newPronunciation } from "types/word"; diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx index e559277b85..97f04046ac 100644 --- a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -3,10 +3,10 @@ import { Provider } from "react-redux"; import renderer from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; +import { defaultState } from "rootRedux/types"; import theme from "types/theme"; import { newPronunciation } from "types/word"; diff --git a/src/components/Pronunciations/tests/RecorderIcon.test.tsx b/src/components/Pronunciations/tests/RecorderIcon.test.tsx index 91d308c8ce..6fda104dac 100644 --- a/src/components/Pronunciations/tests/RecorderIcon.test.tsx +++ b/src/components/Pronunciations/tests/RecorderIcon.test.tsx @@ -8,12 +8,11 @@ import { } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; -import { defaultState } from "components/App/DefaultState"; import RecorderIcon, { recordButtonId, } from "components/Pronunciations/RecorderIcon"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { type StoreState } from "rootRedux/types"; +import { type StoreState, defaultState } from "rootRedux/types"; import theme from "types/theme"; let testRenderer: ReactTestRenderer; diff --git a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx index 63b2149978..051fa8b414 100644 --- a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx +++ b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx @@ -1,12 +1,10 @@ -import { PreloadedState } from "redux"; - -import { defaultState } from "components/App/DefaultState"; import { initTreeDomain, setDomainLanguage, traverseTree, } from "components/TreeView/Redux/TreeViewActions"; -import { RootState, setupStore } from "rootRedux/store"; +import { setupStore } from "rootRedux/store"; +import { persistedDefaultState } from "rootRedux/testTypes"; import { newSemanticDomain, newSemanticDomainTreeNode, @@ -27,12 +25,6 @@ global.analytics = { identify: jest.fn(), track: jest.fn() } as any; const mockId = "1.2.3"; const mockLang = "lang"; -// Preloaded values for store when testing -const persistedDefaultState: PreloadedState = { - ...defaultState, - _persist: { version: 1, rehydrated: false }, -}; - describe("TreeViewActions", () => { describe("setDomainLanguage", () => { it("correctly affects state", async () => { diff --git a/src/components/TreeView/TreeSearch.tsx b/src/components/TreeView/TreeSearch.tsx index 76abe9fa51..69faa9118e 100644 --- a/src/components/TreeView/TreeSearch.tsx +++ b/src/components/TreeView/TreeSearch.tsx @@ -51,7 +51,7 @@ export default function TreeSearch(props: TreeSearchProps): ReactElement { onKeyUp={handleOnKeyUp} margin="normal" autoComplete="off" - inputProps={{ "data-testid": testId }} + inputProps={{ "data-testid": testId, maxLength: 99 }} value={input} error={searchError} helperText={searchError ? t("treeView.domainNotFound") : undefined} @@ -59,17 +59,17 @@ export default function TreeSearch(props: TreeSearchProps): ReactElement { ); } -/** Automatically convert a string of form 123 to 1.2.3. */ +/** Adds periods to a string of digits. + * If string is digits with at most 1 period between each digit + * (e.g.: 1234, 12.34, 123.4, 1.23.4), + * then removes all periods (e.g.: 1234) + * and inserts a period between each digit (e.g.: 1.2.3.4). + * + * Note: doesn't act on strings with double/initial/final period (e.g.: .2.3.4, 1..3.4), + * because a user may be changing a digit (e.g.: 1.0.3.4 -> 1..3.4 -> 1.2.3.4). */ export function insertDecimalPoints(value: string): string { - // Test if input is strictly of the form: 1.2.3 or 123 - if (/^[.\d]+$/.test(value) && !value.endsWith(".")) { - // Automatically insert decimal points between two numbers. - value = value - .replace(/\./g, "") - .split("") - .map((char) => `${char}.`) - .join("") - .slice(0, -1); + if (/^\d(\.?\d)+$/.test(value)) { + value = value.replace(/\./g, "").split("").join("."); } return value; diff --git a/src/components/TreeView/tests/TreeSearch.test.tsx b/src/components/TreeView/tests/TreeSearch.test.tsx index 4d92610547..d24874985c 100644 --- a/src/components/TreeView/tests/TreeSearch.test.tsx +++ b/src/components/TreeView/tests/TreeSearch.test.tsx @@ -131,19 +131,21 @@ describe("TreeSearch", () => { }); describe("insertDecimalPoints", () => { + test.each(["a", "1a", "1", "1.", "1.0", "1-2", "1..2", "1.2.3.", ".123"])( + "does not change", + (input) => { + expect(insertDecimalPoints(input)).toBe(input); + } + ); + test.each([ - ["a", "a"], - ["1a", "1a"], - ["1", "1"], - ["1.", "1."], - ["1.0", "1.0"], ["10", "1.0"], ["12", "1.2"], ["123", "1.2.3"], - ["1.2.3.", "1.2.3."], - ["..1", "1"], - ["1..2", "1.2"], - ])("inserts correctly", (input, output) => { + ["1.23", "1.2.3"], + ["12.3", "1.2.3"], + ["1.23.4", "1.2.3.4"], + ])("changes correctly", (input, output) => { expect(insertDecimalPoints(input)).toBe(output); }); }); diff --git a/src/components/UserSettings/UserSettings.tsx b/src/components/UserSettings/UserSettings.tsx index 020563d743..1b567c1ab6 100644 --- a/src/components/UserSettings/UserSettings.tsx +++ b/src/components/UserSettings/UserSettings.tsx @@ -1,4 +1,4 @@ -import { Email, Phone } from "@mui/icons-material"; +import { Email, HelpOutline, Phone } from "@mui/icons-material"; import { Button, Card, @@ -7,13 +7,14 @@ import { MenuItem, Select, TextField, + Tooltip, Typography, } from "@mui/material"; import { enqueueSnackbar } from "notistack"; import { FormEvent, Fragment, ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; -import { User } from "api/models"; +import { AutocompleteSetting, User } from "api/models"; import { isEmailTaken, updateUser } from "backend"; import { getAvatar, getCurrentUser } from "backend/localStorage"; import { asyncLoadSemanticDomains } from "components/Project/ProjectActions"; @@ -34,6 +35,7 @@ export enum UserSettingsIds { FieldName = "user-settings-name", FieldPhone = "user-settings-phone", FieldUsername = "user-settings-username", + SelectGlossSuggestion = "user-settings-gloss-suggestion", SelectUiLang = "user-settings-ui-lang", } @@ -57,6 +59,9 @@ export function UserSettings(props: { const [phone, setPhone] = useState(props.user.phone); const [email, setEmail] = useState(props.user.email); const [uiLang, setUiLang] = useState(props.user.uiLang ?? ""); + const [glossSuggestion, setGlossSuggestion] = useState( + props.user.glossSuggestion + ); const [emailTaken, setEmailTaken] = useState(false); const [avatar, setAvatar] = useState(getAvatar()); @@ -72,7 +77,8 @@ export function UserSettings(props: { name === props.user.name && phone === props.user.phone && punycode.toUnicode(email) === props.user.email && - uiLang === (props.user.uiLang ?? ""); + uiLang === (props.user.uiLang ?? "") && + glossSuggestion === props.user.glossSuggestion; async function onSubmit(e: FormEvent): Promise { e.preventDefault(); @@ -83,6 +89,7 @@ export function UserSettings(props: { phone, email: punycode.toUnicode(email), uiLang, + glossSuggestion, hasAvatar: !!avatar, }); @@ -226,6 +233,42 @@ export function UserSettings(props: { + + + + {t("userSettings.glossSuggestion")} + + + + + + + + + + + + + +