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 (