From b1b6e344dd1a25acc56cc14365ffc5843283d0fb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Jun 2025 11:24:11 -0400 Subject: [PATCH 01/15] [WordCard, ReviewEntriesCompleted] Migrate from deprecated Grid --- src/components/Buttons/UndoButton.tsx | 54 ++++++++---------- src/components/WordCard/DomainChipsGrid.tsx | 14 +++-- src/components/WordCard/SenseCard.tsx | 7 +-- src/components/WordCard/SummarySenseCard.tsx | 10 ++-- src/components/WordCard/index.tsx | 19 ++++--- .../ReviewEntries/ReviewEntriesCompleted.tsx | 55 ++++++++++--------- 6 files changed, 77 insertions(+), 82 deletions(-) diff --git a/src/components/Buttons/UndoButton.tsx b/src/components/Buttons/UndoButton.tsx index 72d3d79543..ada25bb818 100644 --- a/src/components/Buttons/UndoButton.tsx +++ b/src/components/Buttons/UndoButton.tsx @@ -1,4 +1,4 @@ -import { Button, Grid } from "@mui/material"; +import { Button } from "@mui/material"; import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -30,35 +30,27 @@ export default function UndoButton(props: UndoButtonProps): ReactElement { } }, [isUndoAllowed, undoDialogOpen]); - return ( - - {isUndoEnabled ? ( -
- - setUndoDialogOpen(false)} - handleConfirm={() => - props.undo().then(() => setUndoDialogOpen(false)) - } - buttonIdCancel={props.buttonIdCancel} - buttonIdConfirm={props.buttonIdConfirm} - /> -
- ) : ( -
- -
- )} -
+ return isUndoEnabled ? ( + <> + + setUndoDialogOpen(false)} + handleConfirm={() => props.undo().then(() => setUndoDialogOpen(false))} + buttonIdCancel={props.buttonIdCancel} + buttonIdConfirm={props.buttonIdConfirm} + /> + + ) : ( + ); } diff --git a/src/components/WordCard/DomainChipsGrid.tsx b/src/components/WordCard/DomainChipsGrid.tsx index 19a994e139..3b8b714cd8 100644 --- a/src/components/WordCard/DomainChipsGrid.tsx +++ b/src/components/WordCard/DomainChipsGrid.tsx @@ -1,4 +1,4 @@ -import { Grid } from "@mui/material"; +import { Grid2 } from "@mui/material"; import { type ReactElement } from "react"; import { type SemanticDomain } from "api/models"; @@ -26,12 +26,14 @@ export default function DomainChipsGrid( }; return ( - + {props.semDoms.map((d) => ( - - - + ))} - + ); } diff --git a/src/components/WordCard/SenseCard.tsx b/src/components/WordCard/SenseCard.tsx index 4841f5b9ff..3cc1d43591 100644 --- a/src/components/WordCard/SenseCard.tsx +++ b/src/components/WordCard/SenseCard.tsx @@ -28,12 +28,7 @@ export default function SenseCard(props: SenseCardProps): ReactElement { const semDoms = props.sense.semanticDomains; return ( - + {/* Part of speech (if any) */}
diff --git a/src/components/WordCard/SummarySenseCard.tsx b/src/components/WordCard/SummarySenseCard.tsx index d07dee9f36..54d8702c71 100644 --- a/src/components/WordCard/SummarySenseCard.tsx +++ b/src/components/WordCard/SummarySenseCard.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent, Chip, Grid, Typography } from "@mui/material"; +import { Card, CardContent, Chip, Grid2, Typography } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; @@ -57,13 +57,11 @@ export default function SummarySenseCard( /> {/* Semantic domain numbers */} - + {domIds.map((id) => ( - - - + ))} - + ); diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index a91016cccd..e4a974cc30 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -5,6 +5,7 @@ import { CardContent, CardHeader, IconButton, + Stack, Typography, } from "@mui/material"; import { Fragment, ReactElement, useEffect, useState } from "react"; @@ -130,14 +131,16 @@ export default function WordCard(props: WordCardProps): ReactElement { {/* Senses */} {full ? ( - senses.map((s) => ( - - )) + + {senses.map((s) => ( + + ))} + ) : ( )} diff --git a/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx b/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx index 1fb4a23666..b4a2049f1c 100644 --- a/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx @@ -1,5 +1,5 @@ import { ArrowRightAlt } from "@mui/icons-material"; -import { Grid, List, ListItem, Typography } from "@mui/material"; +import { Box, List, ListItem, Stack, Typography } from "@mui/material"; import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; @@ -65,31 +65,36 @@ function EditedEntry(props: { edit: EntryEdit }): ReactElement { return ( - - {!!oldWord && } - - + {!!oldWord && ( + + + + )} + + + + + + {!!newWord && ( + + + + )} + + + isInFrontier(newId)} + undo={() => undoEdit(props.edit)} /> - - {!!newWord && } - isInFrontier(newId)} - undo={() => undoEdit(props.edit)} - /> - + + ); } From 66a88c316bb25bd2fda96973b09e5b3cf92e0801 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Jun 2025 11:32:46 -0400 Subject: [PATCH 02/15] Also ReviewEntries --- .../ReviewEntriesTable/Cells/DomainsCell.tsx | 14 +- .../Cells/EditCell/EditDialog.tsx | 235 ++++++++---------- .../Cells/EditCell/EditSenseDialog.tsx | 160 ++++++------ .../Cells/EditCell/EditSensesCardContent.tsx | 184 +++++++------- .../Cells/PartOfSpeechCell.tsx | 13 +- 5 files changed, 287 insertions(+), 319 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx index 839c917f14..632cfd84b6 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx @@ -1,4 +1,4 @@ -import { Chip, Grid } from "@mui/material"; +import { Chip, Grid2 } from "@mui/material"; import { type ReactElement } from "react"; import { type SemanticDomain, type Sense } from "api/models"; @@ -19,12 +19,14 @@ export function gatherDomains(senses: Sense[]): SemanticDomain[] { export default function DomainsCell(props: CellProps): ReactElement { return ( - + {gatherDomains(props.word.senses).map((dom) => ( - - - + ))} - + ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx index 5f3428ccd1..b62dbbad16 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx @@ -13,10 +13,11 @@ import { Dialog, DialogContent, DialogTitle, - Grid, + Grid2, IconButton, MenuItem, Select, + Stack, type SelectChangeEvent, } from "@mui/material"; import { grey, yellow } from "@mui/material/colors"; @@ -351,151 +352,135 @@ export default function EditDialog(props: EditDialogProps): ReactElement { /> - - - {t("reviewEntries.columns.edit")} - {" : "} - {props.word.vernacular} - - + + {`${t("reviewEntries.columns.edit")} : ${props.word.vernacular}`} + +
+ - - +
+
+ - + {/* Vernacular */} - - - - - - setNewWord((prev) => ({ - ...prev, - vernacular: e.target.value, - })) - } - value={newWord.vernacular} - vernacular - /> - - - - - {/* Senses */} - - - 1 && ( - setShowSenses((prev) => !prev)} - > - {showSenses ? ( - - ) : ( - - )} - - ) + + + + + setNewWord((prev) => ({ + ...prev, + vernacular: e.target.value, + })) } - title={t("reviewEntries.columns.senses")} - /> - - - + + + + {/* Senses */} + + 1 && ( + setShowSenses((prev) => !prev)} + > + {showSenses ? ( + + ) : ( + + )} + + ) + } + title={t("reviewEntries.columns.senses")} + /> + + {/* Pronunciations */} - - - - - - } - audio={newAudio} - deleteAudio={delNewAudio} - replaceAudio={repNewAudio} - uploadAudio={uplNewAudio} - /> - - - + + + + + } + audio={newAudio} + deleteAudio={delNewAudio} + replaceAudio={repNewAudio} + uploadAudio={uplNewAudio} + /> + + {/* Note */} - - - + + + updateNoteText(e.target.value)} + value={newWord.note.text} /> - - updateNoteText(e.target.value)} - value={newWord.note.text} - /> - - - + + {/* Flag */} - - - - - - {newWord.flag.active ? ( - - ) : ( - - )} - - updateFlag(e.target.value)} - value={newWord.flag.active ? newWord.flag.text : ""} - /> - - - - + + + + + {newWord.flag.active ? ( + + ) : ( + + )} + + updateFlag(e.target.value)} + value={newWord.flag.active ? newWord.flag.text : ""} + /> + + +
diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx index d00ee57c3b..71174cb60e 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx @@ -7,8 +7,9 @@ import { Dialog, DialogContent, DialogTitle, - Grid, + Grid2, IconButton, + Stack, Typography, } from "@mui/material"; import { grey, yellow } from "@mui/material/colors"; @@ -184,106 +185,96 @@ export default function EditSenseDialog( /> - - {t("reviewEntries.editSense")} - + + {t("reviewEntries.editSense")} + +
t.palette.success.main }} /> + t.palette.error.main }} /> - - +
+
+ - + {/* Definitions */} {definitionsEnabled && ( - - - - - - - - - )} - - {/* Glosses */} - - - + + - - + )} + + {/* Glosses */} + + + + + + {/* Part of Speech */} {grammaticalInfoEnabled && ( - - - - - {newSense.grammaticalInfo.catGroup === - GramCatGroup.Unspecified ? ( - - {t("grammaticalCategory.group.Unspecified")} - - ) : ( - - )} - - - - )} - - {/* Semantic Domains */} - - - + + - + {newSense.grammaticalInfo.catGroup === + GramCatGroup.Unspecified ? ( + + {t("grammaticalCategory.group.Unspecified")} + + ) : ( + + )} - - + )} + + {/* Semantic Domains */} + + + + + + +
@@ -450,21 +441,20 @@ function DomainList(props: DomainListProps): ReactElement { return ( <> - + {props.domains.length > 0 ? ( props.domains.map((domain, index) => ( - - deleteDomain(domain.id)} - /> - + deleteDomain(domain.id)} + /> )) ) : ( - + - + )} - + + setAddingDom(false)} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx index 18beddbe43..f02c685cf9 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx @@ -6,9 +6,9 @@ import { Edit, RestoreFromTrash, } from "@mui/icons-material"; -import { CardContent, Divider, Grid, Icon } from "@mui/material"; +import { CardContent, Divider, Grid2, Icon, Stack } from "@mui/material"; import { grey, yellow } from "@mui/material/colors"; -import { type ReactElement, useEffect, useState } from "react"; +import { Fragment, type ReactElement, useEffect, useState } from "react"; import { type Sense, Status } from "api/models"; import { IconButtonWithTooltip } from "components/Buttons"; @@ -55,35 +55,39 @@ export default function EditSensesCardContent( return ( {props.showSenses ? ( - <> + {props.newSenses.map((s, i) => ( - props.moveSense(i, i + 1) - : undefined - } - bumpSenseUp={i ? () => props.moveSense(i, i - 1) : undefined} - edited={changes[i]} - key={s.guid} - sense={s} - toggleSenseDeleted={() => props.toggleSenseDeleted(i)} - updateSense={props.updateOrAddSense} - /> + + props.moveSense(i, i + 1) + : undefined + } + bumpSenseUp={i ? () => props.moveSense(i, i - 1) : undefined} + edited={changes[i]} + sense={s} + toggleSenseDeleted={() => props.toggleSenseDeleted(i)} + updateSense={props.updateOrAddSense} + /> + + ))} + } onClick={() => setAddSense(true)} size="small" /> + setAddSense(false)} isOpen={addSense} save={props.updateOrAddSense} sense={newSense()} /> - + ) : ( - - {props.bumpSenseDown || props.bumpSenseUp ? ( - - - - : } - onClick={props.bumpSenseUp} - size="small" - /> - - - : } - onClick={props.bumpSenseDown} - size="small" - /> - - - - ) : null} - - - {deleted ? ( - - } - onClick={props.toggleSenseDeleted} - size="small" - /> - - ) : ( - <> - - } - onClick={ - sense.accessibility === Status.Protected - ? undefined - : props.toggleSenseDeleted - } - size="small" - textId={ - sense.accessibility === Status.Protected - ? "reviewEntries.deleteDisabled" - : undefined - } - /> - - - } - onClick={() => setEditing(true)} - size="small" - /> - - - )} - - - - + {props.bumpSenseDown || props.bumpSenseUp ? ( + + : } + onClick={props.bumpSenseUp} + size="small" + /> + + : } + onClick={props.bumpSenseDown} + size="small" /> - - setEditing(false)} - isOpen={editing} - save={props.updateSense} + + ) : null} + + + {deleted ? ( + } + onClick={props.toggleSenseDeleted} + size="small" + /> + ) : ( + <> + } + onClick={ + sense.accessibility === Status.Protected + ? undefined + : props.toggleSenseDeleted + } + size="small" + textId={ + sense.accessibility === Status.Protected + ? "reviewEntries.deleteDisabled" + : undefined + } + /> + + } + onClick={() => setEditing(true)} + size="small" + /> + + )} + + +
+ - - - +
+ + setEditing(false)} + isOpen={editing} + save={props.updateSense} + sense={sense} + /> + ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx index df0840d02a..604e1720a9 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx @@ -1,4 +1,4 @@ -import { Grid } from "@mui/material"; +import { Grid2 } from "@mui/material"; import { type ReactElement } from "react"; import { type GrammaticalInfo, type Sense } from "api/models"; @@ -18,12 +18,13 @@ function gatherGramInfo(senses: Sense[]): GrammaticalInfo[] { export default function PartOfSpeechCell(props: CellProps): ReactElement { return ( - + {gatherGramInfo(props.word.senses).map((gi) => ( - - - + ))} - + ); } From b6b8e8bb692a436e54ef126df05221e009e88ad6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Jun 2025 15:01:27 -0400 Subject: [PATCH 03/15] [EditDialog] Move testing to @testing-library --- .../Cells/EditCell/EditDialog.tsx | 13 +- .../Cells/EditCell/EditSensesCardContent.tsx | 6 +- .../Cells/EditCell/tests/EditDialog.test.tsx | 197 +++++------------- .../tests/EditSensesCardContent.test.tsx | 111 ++++++++++ 4 files changed, 175 insertions(+), 152 deletions(-) create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSensesCardContent.test.tsx diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx index b62dbbad16..2b395b419d 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx @@ -356,11 +356,16 @@ export default function EditDialog(props: EditDialogProps): ReactElement { {`${t("reviewEntries.columns.edit")} : ${props.word.vernacular}`}
- + @@ -378,6 +383,9 @@ export default function EditDialog(props: EditDialogProps): ReactElement { setNewWord((prev) => ({ @@ -397,6 +405,7 @@ export default function EditDialog(props: EditDialogProps): ReactElement { action={ newWord.senses.length > 1 && ( setShowSenses((prev) => !prev)} > @@ -454,6 +463,7 @@ export default function EditDialog(props: EditDialogProps): ReactElement { analysis fullWidth id={EditDialogId.TextFieldNote} + inputProps={{ "data-testid": EditDialogId.TextFieldNote }} lang={newWord.note.language} multiline onChange={(e) => updateNoteText(e.target.value)} @@ -475,6 +485,7 @@ export default function EditDialog(props: EditDialogProps): ReactElement { updateFlag(e.target.value)} value={newWord.flag.active ? newWord.flag.text : ""} /> diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx index f02c685cf9..53d1fba1d6 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx @@ -18,7 +18,7 @@ import EditSenseDialog from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCe import { isSenseChanged } from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; import { newSense } from "types/word"; -enum EditSensesId { +export enum EditSensesId { ButtonSenseAdd = "add-sense-button", ButtonSenseBumpDownPrefix = "bump-up-sense-button-", ButtonSenseBumpUpPrefix = "bump-down-sense-button-", @@ -55,7 +55,7 @@ export default function EditSensesCardContent( return ( {props.showSenses ? ( - + {props.newSenses.map((s, i) => ( + {props.bumpSenseDown || props.bumpSenseUp ? ( - jest.requireActual("@mui/material/Container") -); -// Textfield with multiline not supported in react-test-renderer -jest.mock("@mui/material/TextField", () => "div"); - jest.mock("backend", () => ({ - deleteAudio: (...args: any[]) => mockDeleteAudio(...args), + deleteAudio: () => jest.fn(), updateWord: (word: Word) => mockUpdateWord(word), })); jest.mock("components/Pronunciations/AudioRecorder"); jest.mock( - "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog" + "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent", + () => ({ + __esModule: true, + default: (props: { showSenses: boolean }) => ( +
+ ), + }) ); -jest.mock("i18n", () => ({})); jest.mock("rootRedux/hooks", () => ({ ...jest.requireActual("rootRedux/hooks"), useAppDispatch: () => mockDispatch, @@ -41,34 +35,14 @@ jest.mock("rootRedux/hooks", () => ({ const mockClose = jest.fn(); const mockConfirm = jest.fn(); -const mockDeleteAudio = jest.fn(); const mockDispatch = jest.fn(); const mockUpdateWord = jest.fn(); -const mockTextFieldEvent = ( - value: string -): ChangeEvent => - ({ target: { value } }) as any; +const mockSenseStackId = "stack-o-senses"; +const mockSenseSummaryId = "summary-o-senses"; const mockWord = (): Word => ({ ...newWord("vernacular"), - senses: [ - { - ...newSense("gloss 1"), - definitions: [newDefinition("def A", "aa"), newDefinition("def B", "bb")], - }, - { - ...newSense("gloss 2"), - semanticDomains: [newSemanticDomain("2.2", "two-point-two")], - }, - { ...newSense("gloss 3"), accessibility: Status.Protected }, - { - ...newSense("gloss 4"), - grammaticalInfo: { - catGroup: GramCatGroup.Verb, - grammaticalCategory: "vt", - }, - }, - ], + senses: [newSense("gloss1"), newSense("gloss2")], }); const currentProjectState: Partial = { @@ -82,11 +56,9 @@ const currentProjectState: Partial = { }; const mockStore = configureMockStore()({ currentProjectState }); -let renderer: ReactTestRenderer; - const renderEditDialog = async (): Promise => await act(async () => { - renderer = create( + render( @@ -105,12 +77,7 @@ describe("EditDialog", () => { describe("cancel and save buttons", () => { test("cancel button closes if no changes", async () => { // Click the cancel button - const cancelButton = renderer.root.findByProps({ - id: EditDialogId.ButtonCancel, - }); - await act(async () => { - cancelButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditDialogId.ButtonCancel)); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -121,27 +88,14 @@ describe("EditDialog", () => { test("cancel button opens dialog if changes", async () => { // Make a change - const noteTextField = renderer.root.findByProps({ - id: EditDialogId.TextFieldNote, - }); - const newFlagText = "New note!"; - await act(async () => { - noteTextField.props.onChange(mockTextFieldEvent(newFlagText)); - }); + const noteField = screen.getByTestId(EditDialogId.TextFieldNote); + await userEvent.type(noteField, "New note!"); // Click the cancel button and cancel the cancel - const cancelButton = renderer.root.findByProps({ - id: EditDialogId.ButtonCancel, - }); - await act(async () => { - cancelButton.props.onClick(); - }); - const cancelButNoButton = renderer.root.findByProps({ - id: EditDialogId.ButtonCancelDialogCancel, - }); - await act(async () => { - cancelButNoButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditDialogId.ButtonCancel)); + await userEvent.click( + screen.getByTestId(EditDialogId.ButtonCancelDialogCancel) + ); // Ensure nothing happened expect(mockClose).not.toHaveBeenCalled(); @@ -150,15 +104,10 @@ describe("EditDialog", () => { expect(mockDispatch).not.toHaveBeenCalled(); // Click the cancel button and confirm the cancel - await act(async () => { - cancelButton.props.onClick(); - }); - const cancelAndYesButton = renderer.root.findByProps({ - id: EditDialogId.ButtonCancelDialogConfirm, - }); - await act(async () => { - cancelAndYesButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditDialogId.ButtonCancel)); + await userEvent.click( + screen.getByTestId(EditDialogId.ButtonCancelDialogConfirm) + ); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -169,12 +118,7 @@ describe("EditDialog", () => { test("save button closes if no changes", async () => { // Click the save button - const saveButton = renderer.root.findByProps({ - id: EditDialogId.ButtonSave, - }); - await act(async () => { - saveButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditDialogId.ButtonSave)); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -185,21 +129,12 @@ describe("EditDialog", () => { test("save button saves changes and closes", async () => { // Make a change - const flagTextField = renderer.root.findByProps({ - id: EditDialogId.TextFieldFlag, - }); const newFlagText = "New flag!"; - await act(async () => { - flagTextField.props.onChange(mockTextFieldEvent(newFlagText)); - }); + const flagField = screen.getByTestId(EditDialogId.TextFieldFlag); + await userEvent.type(flagField, newFlagText); // Click the save button - const saveButton = renderer.root.findByProps({ - id: EditDialogId.ButtonSave, - }); - await act(async () => { - saveButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditDialogId.ButtonSave)); // Ensure save and close occurred, with dispatch up update goal expect(mockClose).toHaveBeenCalledTimes(1); @@ -211,57 +146,23 @@ describe("EditDialog", () => { }); }); - describe("senses", () => { - test("sense view toggle", async () => { - // Not summary view by default - expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(0); - - // Click to turn on summary view - const button = renderer.root.findByProps({ - id: EditDialogId.ButtonSensesViewToggle, - }); - await act(async () => { - button.props.onClick(); - }); - expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(1); - - // Click again to turn off summary view - await act(async () => { - button.props.onClick(); - }); - expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(0); - }); - - test("add a sense", async () => { - expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); - const senses = renderer.root.findByType(EditSensesCardContent); - await act(async () => { - senses.props.updateOrAddSense(newSense("new gloss")); - }); - expect(renderer.root.findAllByType(EditSense)).toHaveLength(5); - }); - - test("delete/restore a sense", async () => { - expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); - expect(renderer.root.findAllByType(Delete)).toHaveLength(4); - expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(0); - const senses = renderer.root.findByType(EditSensesCardContent); + test("sense-view toggle button", async () => { + // Not summary view by default + expect(screen.queryByTestId(mockSenseStackId)).toBeTruthy(); + expect(screen.queryByTestId(mockSenseSummaryId)).toBeNull(); - // Delete the first sense - await act(async () => { - senses.props.toggleSenseDeleted(0); - }); - expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); - expect(renderer.root.findAllByType(Delete)).toHaveLength(3); - expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(1); + // Click to turn on summary view + await userEvent.click( + screen.getByTestId(EditDialogId.ButtonSensesViewToggle) + ); + expect(screen.queryByTestId(mockSenseStackId)).toBeNull(); + expect(screen.queryByTestId(mockSenseSummaryId)).toBeTruthy(); - // Restore the first sense - await act(async () => { - senses.props.toggleSenseDeleted(0); - }); - expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); - expect(renderer.root.findAllByType(Delete)).toHaveLength(4); - expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(0); - }); + // Click again to turn off summary view + await userEvent.click( + screen.getByTestId(EditDialogId.ButtonSensesViewToggle) + ); + expect(screen.queryByTestId(mockSenseStackId)).toBeTruthy(); + expect(screen.queryByTestId(mockSenseSummaryId)).toBeNull(); }); }); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSensesCardContent.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSensesCardContent.test.tsx new file mode 100644 index 0000000000..957dad9435 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSensesCardContent.test.tsx @@ -0,0 +1,111 @@ +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { GramCatGroup, Sense, Status } from "api/models"; +import EditSensesCardContent, { + EditSensesId, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent"; +import { newSemanticDomain } from "types/semanticDomain"; +import { newDefinition, newSense } from "types/word"; + +jest.mock("components/WordCard/SenseCard"); +jest.mock( + "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog" +); + +const mockMoveSense = jest.fn(); +const mockToggleSenseDeleted = jest.fn(); +const mockUpdateOrAddSense = jest.fn(); + +const mockSenseGuids = ["guid1", "guid2", "guid3", "guid4"]; +const mockSenses = (): Sense[] => [ + { + ...newSense("gloss 1"), + definitions: [newDefinition("def A", "aa"), newDefinition("def B", "bb")], + guid: mockSenseGuids[0], + }, + { + ...newSense("gloss 2"), + guid: mockSenseGuids[1], + semanticDomains: [newSemanticDomain("2.2", "two-point-two")], + }, + { + ...newSense("gloss 3"), + accessibility: Status.Protected, + guid: mockSenseGuids[2], + }, + { + ...newSense("gloss 4"), + grammaticalInfo: { + catGroup: GramCatGroup.Verb, + grammaticalCategory: "vt", + }, + guid: mockSenseGuids[3], + }, +]; + +const renderEditSensesCardContent = async (showSenses = true): Promise => + await act(async () => { + render( + mockMoveSense(from, to)} + newSenses={mockSenses()} + oldSenses={mockSenses()} + showSenses={showSenses} + toggleSenseDeleted={mockToggleSenseDeleted} + updateOrAddSense={mockUpdateOrAddSense} + /> + ); + }); + +beforeEach(async () => { + jest.clearAllMocks(); +}); + +describe("EditSensesCardContent", () => { + it("renders sense summary", async () => { + await renderEditSensesCardContent(false); + expect(screen.queryByRole("list")).toBeNull(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + }); + + it("renders senses", async () => { + await renderEditSensesCardContent(true); + expect(screen.queryByRole("list")).toBeTruthy(); + expect(screen.queryAllByRole("listitem")).toHaveLength(4); + }); + + describe("up/down buttons", () => { + beforeEach(async () => { + await renderEditSensesCardContent(); + }); + + const sensesCount = mockSenses().length; + + for (let i = 0; i < sensesCount; i++) { + test(`move ${i} up`, async () => { + const testId = EditSensesId.ButtonSenseBumpUpPrefix + mockSenseGuids[i]; + if (i) { + await userEvent.click(screen.getByTestId(testId)); + expect(mockMoveSense).toHaveBeenCalledTimes(1); + expect(mockMoveSense).toHaveBeenCalledWith(i, i - 1); + } else { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + + test(`move ${i} down`, async () => { + const testId = + EditSensesId.ButtonSenseBumpDownPrefix + mockSenseGuids[i]; + if (i < sensesCount - 1) { + await userEvent.click(screen.getByTestId(testId)); + expect(mockMoveSense).toHaveBeenCalledTimes(1); + expect(mockMoveSense).toHaveBeenCalledWith(i, i + 1); + } else { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + } + }); +}); From a4d90d1a4956bbfbbc5c7b8cc34081f516378f89 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Jun 2025 15:19:46 -0400 Subject: [PATCH 04/15] [EditSenseDialog] Move testing to @testing-library --- .../Cells/EditCell/EditSenseDialog.tsx | 6 + .../EditCell/tests/EditSenseDialog.test.tsx | 119 ++++-------------- 2 files changed, 30 insertions(+), 95 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx index 71174cb60e..4562ae6cad 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx @@ -190,6 +190,7 @@ export default function EditSenseDialog(
@@ -197,6 +198,7 @@ export default function EditSenseDialog( @@ -328,6 +330,7 @@ function DefinitionTextField(props: DefinitionTextFieldProps): ReactElement { error={props.error} fullWidth id={props.textFieldId} + inputProps={{ "data-testid": props.textFieldId }} label={props.definition.language} lang={props.definition.language} margin="dense" @@ -390,6 +393,7 @@ function GlossTextField(props: GlossTextFieldProps): ReactElement { error={props.error} fullWidth id={props.textFieldId} + inputProps={{ "data-testid": props.textFieldId }} label={props.gloss.language} lang={props.gloss.language} margin="dense" @@ -445,6 +449,7 @@ function DomainList(props: DomainListProps): ReactElement { {props.domains.length > 0 ? ( props.domains.map((domain, index) => ( )} { e.currentTarget.blur(); // else dialog reopens when domain selected with Enter diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx index 7f6a7b1cc3..946c32fdc2 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx @@ -1,6 +1,6 @@ -import { type ChangeEvent } from "react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Provider } from "react-redux"; -import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import { Project, type Sense } from "api/models"; @@ -10,27 +10,9 @@ import EditSenseDialog, { import { type StoreState, defaultState } from "rootRedux/types"; import { newSense } from "types/word"; -// Dialog uses Portal, not supported in react-test-renderer -jest.mock("@mui/material/Dialog", () => - jest.requireActual("@mui/material/Container") -); -// Textfield with multiline not supported in react-test-renderer -jest.mock("@mui/material/TextField", () => "div"); - -jest.mock("components/TreeView", () => "div"); -jest.mock("rootRedux/hooks", () => ({ - ...jest.requireActual("rootRedux/hooks"), - useAppDispatch: () => jest.fn(), -})); - const mockClose = jest.fn(); const mockSave = jest.fn(); -const mockTextFieldEvent = ( - value: string -): ChangeEvent => - ({ target: { value } }) as any; - const mockState = ( definitionsEnabled = false, grammaticalInfoEnabled = false @@ -46,8 +28,6 @@ const mockState = ( }; }; -let renderer: ReactTestRenderer; - const renderEditSenseDialog = async ( definitionsEnabled = false, grammaticalInfoEnabled = false @@ -56,7 +36,7 @@ const renderEditSenseDialog = async ( mockState(definitionsEnabled, grammaticalInfoEnabled) ); await act(async () => { - renderer = create( + render( { test("cancel button closes if no changes", async () => { // Click the cancel button - const cancelButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonCancel, - }); - await act(async () => { - cancelButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditSenseDialogId.ButtonCancel)); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -95,42 +70,24 @@ describe("EditSenseDialog", () => { test("cancel button opens dialog if changes", async () => { // Make a change - const glossTextField = renderer.root.findByProps({ - id: `${EditSenseDialogId.TextFieldGlossPrefix}0`, - }); - const newGlossText = "New gloss!"; - await act(async () => { - glossTextField.props.onChange(mockTextFieldEvent(newGlossText)); - }); + const testId = `${EditSenseDialogId.TextFieldGlossPrefix}0`; + await userEvent.type(screen.getByTestId(testId), "glossier"); // Click the cancel button and cancel the cancel - const cancelButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonCancel, - }); - await act(async () => { - cancelButton.props.onClick(); - }); - const cancelButNoButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonCancelDialogCancel, - }); - await act(async () => { - cancelButNoButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditSenseDialogId.ButtonCancel)); + await userEvent.click( + screen.getByTestId(EditSenseDialogId.ButtonCancelDialogCancel) + ); // Ensure nothing happened expect(mockClose).not.toHaveBeenCalled(); expect(mockSave).not.toHaveBeenCalled(); // Click the cancel button and confirm the cancel - await act(async () => { - cancelButton.props.onClick(); - }); - const cancelAndYesButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonCancelDialogConfirm, - }); - await act(async () => { - cancelAndYesButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditSenseDialogId.ButtonCancel)); + await userEvent.click( + screen.getByTestId(EditSenseDialogId.ButtonCancelDialogConfirm) + ); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -139,12 +96,7 @@ describe("EditSenseDialog", () => { test("save button closes if no changes", async () => { // Click the save button - const saveButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonSave, - }); - await act(async () => { - saveButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditSenseDialogId.ButtonSave)); // Ensure a close without saving expect(mockClose).toHaveBeenCalledTimes(1); @@ -153,21 +105,14 @@ describe("EditSenseDialog", () => { test("save button saves changes and closes", async () => { // Make a change - const glossTextField = renderer.root.findByProps({ - id: `${EditSenseDialogId.TextFieldGlossPrefix}0`, - }); + const testId = `${EditSenseDialogId.TextFieldGlossPrefix}0`; + const glossField = screen.getByTestId(testId); + await userEvent.clear(glossField); const newGlossText = "New gloss!"; - await act(async () => { - glossTextField.props.onChange(mockTextFieldEvent(newGlossText)); - }); + await userEvent.type(glossField, newGlossText); // Click the save button - const saveButton = renderer.root.findByProps({ - id: EditSenseDialogId.ButtonSave, - }); - await act(async () => { - saveButton.props.onClick(); - }); + await userEvent.click(screen.getByTestId(EditSenseDialogId.ButtonSave)); // Ensure save and close occurred expect(mockClose).toHaveBeenCalledTimes(1); @@ -183,30 +128,14 @@ describe("EditSenseDialog", () => { test("show definitions when definitionsEnabled is true", async () => { await renderEditSenseDialog(true, false); - expect( - renderer.root.findAllByProps({ - title: definitionsTitle, - }) - ).toHaveLength(1); - expect( - renderer.root.findAllByProps({ - title: partOfSpeechTitle, - }) - ).toHaveLength(0); + expect(screen.queryByText(definitionsTitle)).toBeTruthy(); + expect(screen.queryByText(partOfSpeechTitle)).toBeNull(); }); test("show part of speech when grammaticalInfoEnabled is true", async () => { await renderEditSenseDialog(false, true); - expect( - renderer.root.findAllByProps({ - title: definitionsTitle, - }) - ).toHaveLength(0); - expect( - renderer.root.findAllByProps({ - title: partOfSpeechTitle, - }) - ).toHaveLength(1); + expect(screen.queryByText(definitionsTitle)).toBeNull(); + expect(screen.queryByText(partOfSpeechTitle)).toBeTruthy(); }); }); }); From e387eb3579789ed72248426639662105a245aa5d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Jun 2025 15:28:32 -0400 Subject: [PATCH 05/15] Tidy --- .../ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx index 85d8f76c19..b26ebcd578 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx @@ -129,8 +129,8 @@ describe("EditDialog", () => { test("save button saves changes and closes", async () => { // Make a change - const newFlagText = "New flag!"; const flagField = screen.getByTestId(EditDialogId.TextFieldFlag); + const newFlagText = "New flag!"; await userEvent.type(flagField, newFlagText); // Click the save button From 4c892381ff2724977c406d6db821f9a55b1d7516 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Jun 2025 10:47:02 -0400 Subject: [PATCH 06/15] Restore necessary i18n mock --- .../ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx index b26ebcd578..0d64ac45c7 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx @@ -28,6 +28,7 @@ jest.mock( ), }) ); +jest.mock("i18n", () => ({})); // else `thrown: "Error: AggregateError` jest.mock("rootRedux/hooks", () => ({ ...jest.requireActual("rootRedux/hooks"), useAppDispatch: () => mockDispatch, From d0ea8fb165916fceba7cb6cc2b4a6c84e788e060 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Jun 2025 10:54:23 -0400 Subject: [PATCH 07/15] Tidy --- .../Cells/EditCell/tests/EditDialog.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx index 0d64ac45c7..131e18cc7f 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx @@ -41,6 +41,8 @@ const mockUpdateWord = jest.fn(); const mockSenseStackId = "stack-o-senses"; const mockSenseSummaryId = "summary-o-senses"; + +/** Returns minimalist multi-sense word. */ const mockWord = (): Word => ({ ...newWord("vernacular"), senses: [newSense("gloss1"), newSense("gloss2")], @@ -153,16 +155,13 @@ describe("EditDialog", () => { expect(screen.queryByTestId(mockSenseSummaryId)).toBeNull(); // Click to turn on summary view - await userEvent.click( - screen.getByTestId(EditDialogId.ButtonSensesViewToggle) - ); + const button = screen.getByTestId(EditDialogId.ButtonSensesViewToggle); + await userEvent.click(button); expect(screen.queryByTestId(mockSenseStackId)).toBeNull(); expect(screen.queryByTestId(mockSenseSummaryId)).toBeTruthy(); // Click again to turn off summary view - await userEvent.click( - screen.getByTestId(EditDialogId.ButtonSensesViewToggle) - ); + await userEvent.click(button); expect(screen.queryByTestId(mockSenseStackId)).toBeTruthy(); expect(screen.queryByTestId(mockSenseSummaryId)).toBeNull(); }); From 8886e74cc64a25d99111eb8fcaa83299aef8037a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Jun 2025 11:55:05 -0400 Subject: [PATCH 08/15] [ReviewEntriesTable] Migrate tests to @testing-library --- .../ReviewEntriesTable/tests/WordsMock.ts | 10 ++- .../ReviewEntriesTable/tests/index.test.tsx | 88 ++++++++----------- 2 files changed, 41 insertions(+), 57 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts b/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts index ea88df4254..d2afc8f74e 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts @@ -51,6 +51,8 @@ senses[3][1].semanticDomains.push(newSemanticDomain("1")); senses[0][0].semanticDomains.push(newSemanticDomain("3")); // (leave senses[1] without semantic domains) +export const verns = ["Alfa", "Delta", "Bravo", "Charlie"]; + // Defined words for vernaculars sorted order [0, 2, 3, 1] // and for pronunciations sorted order [3, 2, 1, 0] // and for flags sorted order [1, 0, 3, 2] @@ -59,7 +61,7 @@ senses[0][0].semanticDomains.push(newSemanticDomain("3")); export function mockWords(): Word[] { return [ { - ...newWord("Alfa"), + ...newWord(verns[0]), audio: [newPronunciation(), newPronunciation(), newPronunciation()], flag: newFlag("India"), id: "0", @@ -67,7 +69,7 @@ export function mockWords(): Word[] { senses: senses[0], }, { - ...newWord("Delta"), + ...newWord(verns[1]), audio: [newPronunciation(), newPronunciation()], flag: { active: true, text: "" }, // (active flag with empty text is sorted to first) id: "1", @@ -75,7 +77,7 @@ export function mockWords(): Word[] { senses: senses[1], }, { - ...newWord("Bravo"), + ...newWord(verns[2]), audio: [newPronunciation()], // (flag with `active = false` is sorted to last) id: "2", @@ -83,7 +85,7 @@ export function mockWords(): Word[] { senses: senses[2], }, { - ...newWord("Charlie"), + ...newWord(verns[3]), flag: newFlag("Juliett"), id: "3", note: newNote("Lima"), diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx index 7c1c6239d3..dcdce10120 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx @@ -1,25 +1,17 @@ -import { TableSortLabel } from "@mui/material"; -import { MRT_TableBodyRow, MRT_TableHeadCell } from "material-react-table"; +import { act, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Provider } from "react-redux"; -import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import { defaultState } from "components/Project/ProjectReduxTypes"; -import ReviewEntriesTable, { - ColumnId, -} from "goals/ReviewEntries/ReviewEntriesTable"; -import VernacularCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell"; +import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesTable"; import { mockWords, sortOrder, + verns, } from "goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock"; import { type StoreState } from "rootRedux/types"; -// With `columnFilterDisplayMode: "popover",`, it is necessary to mock out `Grow`. -// To access filter `TextField`s, replace both `Grow`, `Modal` with `div`. -// However, using a `TextField`'s `.props.onChange()` doesn't activate a filter. -jest.mock("@mui/material/Grow", () => "div"); - // Intercept i18n to set the resolvedLanguage for localization testing. jest.mock("react-i18next", () => ({ ...jest.requireActual("react-i18next"), @@ -34,15 +26,13 @@ const setMockUseTranslation = (resolvedLanguage: string): void => { }; jest.mock("backend", () => ({ - getAllSpeakers: (projectId: string) => mockGetAllSpeakers(projectId), + getAllSpeakers: () => Promise.resolve([]), getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), getWord: (wordId: string) => mockGetWord(wordId), })); jest.mock("components/Pronunciations/PronunciationsBackend"); jest.mock("i18n", () => ({})); -const mockClickEvent = { stopPropagation: jest.fn() }; -const mockGetAllSpeakers = jest.fn(); const mockGetFrontierWords = jest.fn(); const mockGetWord = jest.fn(); const mockState = ( @@ -59,8 +49,6 @@ const mockState = ( }, }); -let renderer: ReactTestRenderer; - const renderReviewEntriesTable = async ( definitionsEnabled = false, grammaticalInfoEnabled = false, @@ -71,7 +59,7 @@ const renderReviewEntriesTable = async ( mockState(definitionsEnabled, grammaticalInfoEnabled) ); await act(async () => { - renderer = create( + render( @@ -81,7 +69,6 @@ const renderReviewEntriesTable = async ( function setMockFunctions(): void { jest.clearAllMocks(); - mockGetAllSpeakers.mockResolvedValue([]); mockGetFrontierWords.mockResolvedValue(mockWords()); } @@ -93,7 +80,8 @@ describe("ReviewEntriesTable", () => { test("initial render fetches frontier and loads data", async () => { await renderReviewEntriesTable(); expect(mockGetFrontierWords).toHaveBeenCalled(); - expect(renderer.root.findAllByType(MRT_TableBodyRow)).toHaveLength(4); + const rowCount = mockWords().length + 1; // +1 for header row + expect(screen.getAllByRole("row")).toHaveLength(rowCount); }); describe("table sort", () => { @@ -103,15 +91,22 @@ describe("ReviewEntriesTable", () => { /** Checks if the WordsMock.tsx words have been sorted by the given column. */ const checkRowOrder = (col: number, dir: "asc" | "desc"): void => { - const rowIds = renderer.root - .findAllByType(VernacularCell) - .map((cell) => cell.props.word.id); + // The mock verns are distinct strings, + // so they can be used to determine row permutation. + const rows = screen.getAllByRole("row").slice(1); + const sorted = verns.map((v) => + rows.findIndex((r) => within(r).queryByText(v)) + ); + + // Get what the row order should be. const order = [...sortOrder[col]]; if (dir === "desc") { order.reverse(); } - order.forEach((id, index) => { - expect(rowIds[index]).toEqual(`${id}`); + + // Verify the permutation. + sorted.forEach((rowIndex, wordIndex) => { + expect(order[rowIndex]).toEqual(wordIndex); }); }; @@ -130,43 +125,32 @@ describe("ReviewEntriesTable", () => { cols.forEach((col, i) => { test(`sorting by ${col} column`, async () => { - const button = renderer.root.findAllByType(TableSortLabel)[i]; - expect(button.props.direction).toBeUndefined(); - await act(async () => { - button.props.onClick(mockClickEvent); - }); - expect(button.props.direction).toEqual("asc"); + // The icon changes when clicked, so use the surrounding span. + const button = screen.getAllByTestId("SyncAltIcon")[i].closest("span"); + + await userEvent.click(button!); checkRowOrder(i, "asc"); - await act(async () => { - button.props.onClick(mockClickEvent); - }); - expect(button.props.direction).toEqual("desc"); + + await userEvent.click(button!); checkRowOrder(i, "desc"); - await act(async () => { - button.props.onClick(mockClickEvent); - }); - expect(button.props.direction).toBeUndefined(); }); }); }); describe("definitionsEnabled & grammaticalInfoEnabled", () => { + const defTextId = "reviewEntries.columns.definitions"; + const posTextId = "reviewEntries.columns.partOfSpeech"; + test("show definitions when definitionsEnabled is true", async () => { await renderReviewEntriesTable(true, false); - const colIds = renderer.root - .findAllByType(MRT_TableHeadCell) - .map((col) => col.props.header.id); - expect(colIds).toContain(ColumnId.Definitions); - expect(colIds).not.toContain(ColumnId.PartOfSpeech); + expect(screen.queryByText(defTextId)).toBeTruthy(); + expect(screen.queryByText(posTextId)).toBeNull(); }); test("show part of speech when grammaticalInfoEnabled is true", async () => { await renderReviewEntriesTable(false, true); - const colIds = renderer.root - .findAllByType(MRT_TableHeadCell) - .map((col) => col.props.header.id); - expect(colIds).not.toContain(ColumnId.Definitions); - expect(colIds).toContain(ColumnId.PartOfSpeech); + expect(screen.queryByText(defTextId)).toBeNull(); + expect(screen.queryByText(posTextId)).toBeTruthy(); }); }); @@ -183,15 +167,13 @@ describe("ReviewEntriesTable", () => { test("defaults to en", async () => { await renderReviewEntriesTable(); - // Throws error if no component found with specified `title` prop - renderer.root.findByProps({ title: localizedText["en"] }); + screen.getByLabelText(localizedText["en"]); // throws if none }); Object.entries(localizedText).forEach(([lang, text]) => { test(lang, async () => { await renderReviewEntriesTable(false, false, lang); - // Throws error if no component found with specified `title` prop - renderer.root.findByProps({ title: text }); + screen.getByLabelText(text); // throws if none }); }); }); From aad4ceb222e558c9c651cc107c00bf5e3e1366a2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Jun 2025 12:03:14 -0400 Subject: [PATCH 09/15] Simplify row-order check --- .../ReviewEntriesTable/tests/index.test.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx index dcdce10120..c06b0bfd4f 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx @@ -91,23 +91,18 @@ describe("ReviewEntriesTable", () => { /** Checks if the WordsMock.tsx words have been sorted by the given column. */ const checkRowOrder = (col: number, dir: "asc" | "desc"): void => { - // The mock verns are distinct strings, - // so they can be used to determine row permutation. - const rows = screen.getAllByRole("row").slice(1); - const sorted = verns.map((v) => - rows.findIndex((r) => within(r).queryByText(v)) - ); - - // Get what the row order should be. + // Use distinct mock verns to determine sorted order. + const sorted = screen + .getAllByRole("row") + .slice(1) + .map((r) => verns.findIndex((v) => within(r).queryByText(v))); + + // Verify the order is right. const order = [...sortOrder[col]]; if (dir === "desc") { order.reverse(); } - - // Verify the permutation. - sorted.forEach((rowIndex, wordIndex) => { - expect(order[rowIndex]).toEqual(wordIndex); - }); + expect(sorted).toEqual(order); }; /** The accessor columns in default order. */ From 2743567999a7bd11205bb2e97fefcf518f5a926e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 19 Jun 2025 11:30:32 -0400 Subject: [PATCH 10/15] Drop unneeded color adjustment --- src/components/WordCard/index.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index e4a974cc30..d21c8be111 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -90,13 +90,7 @@ export default function WordCard(props: WordCardProps): ReactElement { buttonLabel={ full ? WordCardLabel.ButtonCondense : WordCardLabel.ButtonExpand } - icon={ - full ? ( - t.palette.grey[900] }} /> - ) : ( - t.palette.grey[600] }} /> - ) - } + icon={full ? : } onClick={() => setFull(!full)} /> From 972e1b68112c74d6c895c93280a0e228d863b25f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 19 Jun 2025 11:30:39 -0400 Subject: [PATCH 11/15] Use color shorthand --- .../Cells/EditCell/EditDialog.tsx | 7 +++---- .../Cells/EditCell/EditSenseDialog.tsx | 4 ++-- .../ReviewEntries/ReviewEntriesTable/index.tsx | 17 +++-------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx index 2b395b419d..38a567280d 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx @@ -44,7 +44,6 @@ import { } from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState, type StoreStateDispatch } from "rootRedux/types"; -import { themeColors } from "types/theme"; import { type FileWithSpeakerId, newPronunciation, @@ -361,7 +360,7 @@ export default function EditDialog(props: EditDialogProps): ReactElement { id={EditDialogId.ButtonSave} onClick={saveAndClose} > - + - +
@@ -478,7 +477,7 @@ export default function EditDialog(props: EditDialogProps): ReactElement { {newWord.flag.active ? ( - + ) : ( )} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx index 4562ae6cad..c163ef8cdb 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx @@ -194,7 +194,7 @@ export default function EditSenseDialog( id={EditSenseDialogId.ButtonSave} onClick={saveAndClose} > - t.palette.success.main }} /> + - t.palette.error.main }} /> +
diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx index b8dfaff759..33c4e4009d 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx @@ -305,14 +305,8 @@ export default function ReviewEntriesTable(props: { filterFn: ff.filterFnPronunciations(speakers), Header: ( <> - t.palette.error.main }} - /> - t.palette.success.main }} - /> + + ), header: t("reviewEntries.columns.pronunciations"), @@ -344,12 +338,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor("flag", { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnFlag, - Header: ( - t.palette.error.main }} - /> - ), + Header: , header: t("reviewEntries.columns.flag"), id: ColumnId.Flag, muiTableHeadCellProps: { From 5af9447122d811bda03e6e74e072ffca0f20b6bc Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 13 Aug 2025 15:42:44 -0400 Subject: [PATCH 12/15] Tidy --- .../ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx | 2 +- .../Cells/EditCell/EditSensesCardContent.tsx | 6 +++--- .../Cells/EditCell/tests/EditDialog.test.tsx | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx index 632cfd84b6..16379483bc 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx @@ -24,7 +24,7 @@ export default function DomainsCell(props: CellProps): ReactElement { ))} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx index 5ffa3ee366..1c11bc7446 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx @@ -6,7 +6,7 @@ import { Edit, RestoreFromTrash, } from "@mui/icons-material"; -import { CardContent, Divider, Grid2, Icon, Stack } from "@mui/material"; +import { Box, CardContent, Divider, Grid2, Icon, Stack } from "@mui/material"; import { grey, yellow } from "@mui/material/colors"; import { Fragment, type ReactElement, useEffect, useState } from "react"; @@ -170,12 +170,12 @@ export function EditSense(props: EditSenseProps): ReactElement { )}
-
+ -
+ setEditing(false)} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx index f5bdd6276f..e48a76d15b 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx @@ -21,10 +21,8 @@ jest.mock( "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent", () => ({ __esModule: true, - default: (props: { showSenses: boolean }) => ( -
+ default: ({ showSenses }: { showSenses: boolean }) => ( +
), }) ); From db93f18793e822ff8fdca778dbd760775cfcb7ee Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 15 Aug 2025 09:46:30 -0400 Subject: [PATCH 13/15] Update DomainChip --- .../GoalTimeline/GoalHistoryButton.tsx | 11 ++-------- src/components/WordCard/DomainChip.tsx | 19 +++++++++++------ src/components/WordCard/SummarySenseCard.tsx | 13 +++++++----- src/components/WordCard/index.tsx | 9 +++++--- src/utilities/utilities.ts | 21 +++++++++++++------ 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 29f87cd6d6..2fdbac2a2b 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -10,6 +10,7 @@ import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; import { Goal, GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; +import { getLocalizedDateTimeString } from "utilities/utilities"; interface GoalHistoryButtonProps { goal: Goal; @@ -23,15 +24,7 @@ export default function GoalHistoryButton( const { i18n, t } = useTranslation(); const modifiedFormatted = goal.modified - ? new Date(goal.modified).toLocaleString(i18n.resolvedLanguage, { - day: "numeric", - hour: "numeric", - hour12: true, - minute: "2-digit", - month: "short", - weekday: "short", - year: "numeric", - }) + ? getLocalizedDateTimeString(goal.modified, i18n.resolvedLanguage) : null; return ( diff --git a/src/components/WordCard/DomainChip.tsx b/src/components/WordCard/DomainChip.tsx index 28b5a7a156..5e65a76f2b 100644 --- a/src/components/WordCard/DomainChip.tsx +++ b/src/components/WordCard/DomainChip.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { SemanticDomain } from "api/models"; import { getUser } from "backend"; -import { friendlySep, getDateTimeString } from "utilities/utilities"; +import { getLocalizedDateTimeString } from "utilities/utilities"; export function domainLabel(domain: SemanticDomain): string { return `${domain.id}: ${domain.name}`; @@ -12,15 +12,16 @@ export function domainLabel(domain: SemanticDomain): string { interface DomainChipProps { domain: SemanticDomain; + onlyId?: boolean; provenance?: boolean; } export default function DomainChip(props: DomainChipProps): ReactElement { - const { provenance } = props; - const { created, userId } = props.domain; + const { domain, onlyId, provenance } = props; + const { created, id, userId } = domain; const [username, setUsername] = useState(""); - const { t } = useTranslation(); + const { i18n, t } = useTranslation(); useEffect(() => { if (provenance && userId) { @@ -29,14 +30,20 @@ export default function DomainChip(props: DomainChipProps): ReactElement { }, [provenance, userId]); const hoverText = []; + if (onlyId) { + hoverText.push(domainLabel(domain)); + } if (provenance && created) { - const val = getDateTimeString(created, friendlySep); + const val = getLocalizedDateTimeString(created, i18n.resolvedLanguage); hoverText.push(t("wordCard.domainAdded", { val })); } if (provenance && username) { hoverText.push(t("wordCard.user", { val: username })); } return ( - + ); } diff --git a/src/components/WordCard/SummarySenseCard.tsx b/src/components/WordCard/SummarySenseCard.tsx index c6dd43a349..795d57ae34 100644 --- a/src/components/WordCard/SummarySenseCard.tsx +++ b/src/components/WordCard/SummarySenseCard.tsx @@ -1,9 +1,10 @@ -import { Card, CardContent, Chip, Grid2, Typography } from "@mui/material"; +import { Card, CardContent, Grid2, Typography } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { GramCatGroup, Sense } from "api/models"; import { PartOfSpeechButton } from "components/Buttons"; +import DomainChip from "components/WordCard/DomainChip"; import SensesTextSummary from "components/WordCard/SensesTextSummary"; import { groupGramInfo } from "utilities/wordUtilities"; @@ -23,9 +24,11 @@ export default function SummarySenseCard( props.senses.map((s) => s.grammaticalInfo) ).filter((info) => info.catGroup !== GramCatGroup.Unspecified); - // Create a list of distinct semantic domain ids. + // Create a list of semantic domains with distinct ids. const semDoms = props.senses.flatMap((s) => s.semanticDomains); - const domIds = [...new Set(semDoms.map((d) => d.id))].sort(); + const sortedDoms = [...new Set(semDoms.map((d) => d.id))] + .sort() + .map((id) => semDoms.find((dom) => dom.id === id)!); return ( @@ -53,8 +56,8 @@ export default function SummarySenseCard( {/* Semantic domain numbers */} - {domIds.map((id) => ( - + {sortedDoms.map((dom) => ( + ))} diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index d21c8be111..b47d344f2e 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -22,7 +22,7 @@ import PronunciationsBackend from "components/Pronunciations/PronunciationsBacke import SenseCard from "components/WordCard/SenseCard"; import SummarySenseCard from "components/WordCard/SummarySenseCard"; import { TypographyWithFont } from "utilities/fontComponents"; -import { friendlySep, getDateTimeString } from "utilities/utilities"; +import { getLocalizedDateTimeString } from "utilities/utilities"; interface WordCardProps { languages?: string[]; @@ -46,7 +46,7 @@ export default function WordCard(props: WordCardProps): ReactElement { const { audio, editedBy, flag, id, note, senses } = word; const [full, setFull] = useState(false); const [username, setUsername] = useState(""); - const { t } = useTranslation(); + const { i18n, t } = useTranslation(); useEffect(() => { if (provenance && editedBy?.length) { @@ -145,7 +145,10 @@ export default function WordCard(props: WordCardProps): ReactElement { {t("wordCard.wordId", { val: id })}
{t("wordCard.wordModified", { - val: getDateTimeString(word.modified, friendlySep), + val: getLocalizedDateTimeString( + word.modified, + i18n.resolvedLanguage + ), })} {!!username && ( <> diff --git a/src/utilities/utilities.ts b/src/utilities/utilities.ts index cbe0ec46c2..0d002cfbf4 100644 --- a/src/utilities/utilities.ts +++ b/src/utilities/utilities.ts @@ -43,12 +43,6 @@ interface DateTimeSeparators { time?: string; } -export const friendlySep: DateTimeSeparators = { - date: "/", - dateTime: " ", - time: ":", -}; - const pathSep: DateTimeSeparators = { date: "-", dateTime: "_", @@ -78,6 +72,21 @@ export function getDateTimeString( return `${dateString}${sep?.dateTime ?? pathSep.dateTime}${timeString}`; } +export function getLocalizedDateTimeString( + utcString?: string, + lang?: string +): string { + return new Date(utcString ?? Date.now()).toLocaleString(lang, { + day: "numeric", + hour: "numeric", + hour12: true, + minute: "2-digit", + month: "short", + weekday: "short", + year: "numeric", + }); +} + // A general-purpose edit distance. export interface LevenshteinDistParams { delCost: number; From 5ea9cab4ae9fca201e6f1555850e51b87e730f0f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 15 Aug 2025 10:00:25 -0400 Subject: [PATCH 14/15] Extract #3933 --- .../GoalTimeline/GoalHistoryButton.tsx | 11 ++++++++-- src/components/WordCard/DomainChip.tsx | 19 ++++++----------- src/components/WordCard/SummarySenseCard.tsx | 13 +++++------- src/components/WordCard/index.tsx | 9 +++----- src/utilities/utilities.ts | 21 ++++++------------- 5 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/components/GoalTimeline/GoalHistoryButton.tsx b/src/components/GoalTimeline/GoalHistoryButton.tsx index 2fdbac2a2b..29f87cd6d6 100644 --- a/src/components/GoalTimeline/GoalHistoryButton.tsx +++ b/src/components/GoalTimeline/GoalHistoryButton.tsx @@ -10,7 +10,6 @@ import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; import { Goal, GoalName } from "types/goals"; import { goalNameToIcon } from "utilities/goalUtilities"; -import { getLocalizedDateTimeString } from "utilities/utilities"; interface GoalHistoryButtonProps { goal: Goal; @@ -24,7 +23,15 @@ export default function GoalHistoryButton( const { i18n, t } = useTranslation(); const modifiedFormatted = goal.modified - ? getLocalizedDateTimeString(goal.modified, i18n.resolvedLanguage) + ? new Date(goal.modified).toLocaleString(i18n.resolvedLanguage, { + day: "numeric", + hour: "numeric", + hour12: true, + minute: "2-digit", + month: "short", + weekday: "short", + year: "numeric", + }) : null; return ( diff --git a/src/components/WordCard/DomainChip.tsx b/src/components/WordCard/DomainChip.tsx index 5e65a76f2b..28b5a7a156 100644 --- a/src/components/WordCard/DomainChip.tsx +++ b/src/components/WordCard/DomainChip.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { SemanticDomain } from "api/models"; import { getUser } from "backend"; -import { getLocalizedDateTimeString } from "utilities/utilities"; +import { friendlySep, getDateTimeString } from "utilities/utilities"; export function domainLabel(domain: SemanticDomain): string { return `${domain.id}: ${domain.name}`; @@ -12,16 +12,15 @@ export function domainLabel(domain: SemanticDomain): string { interface DomainChipProps { domain: SemanticDomain; - onlyId?: boolean; provenance?: boolean; } export default function DomainChip(props: DomainChipProps): ReactElement { - const { domain, onlyId, provenance } = props; - const { created, id, userId } = domain; + const { provenance } = props; + const { created, userId } = props.domain; const [username, setUsername] = useState(""); - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); useEffect(() => { if (provenance && userId) { @@ -30,20 +29,14 @@ export default function DomainChip(props: DomainChipProps): ReactElement { }, [provenance, userId]); const hoverText = []; - if (onlyId) { - hoverText.push(domainLabel(domain)); - } if (provenance && created) { - const val = getLocalizedDateTimeString(created, i18n.resolvedLanguage); + const val = getDateTimeString(created, friendlySep); hoverText.push(t("wordCard.domainAdded", { val })); } if (provenance && username) { hoverText.push(t("wordCard.user", { val: username })); } return ( - + ); } diff --git a/src/components/WordCard/SummarySenseCard.tsx b/src/components/WordCard/SummarySenseCard.tsx index 795d57ae34..c6dd43a349 100644 --- a/src/components/WordCard/SummarySenseCard.tsx +++ b/src/components/WordCard/SummarySenseCard.tsx @@ -1,10 +1,9 @@ -import { Card, CardContent, Grid2, Typography } from "@mui/material"; +import { Card, CardContent, Chip, Grid2, Typography } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { GramCatGroup, Sense } from "api/models"; import { PartOfSpeechButton } from "components/Buttons"; -import DomainChip from "components/WordCard/DomainChip"; import SensesTextSummary from "components/WordCard/SensesTextSummary"; import { groupGramInfo } from "utilities/wordUtilities"; @@ -24,11 +23,9 @@ export default function SummarySenseCard( props.senses.map((s) => s.grammaticalInfo) ).filter((info) => info.catGroup !== GramCatGroup.Unspecified); - // Create a list of semantic domains with distinct ids. + // Create a list of distinct semantic domain ids. const semDoms = props.senses.flatMap((s) => s.semanticDomains); - const sortedDoms = [...new Set(semDoms.map((d) => d.id))] - .sort() - .map((id) => semDoms.find((dom) => dom.id === id)!); + const domIds = [...new Set(semDoms.map((d) => d.id))].sort(); return ( @@ -56,8 +53,8 @@ export default function SummarySenseCard( {/* Semantic domain numbers */} - {sortedDoms.map((dom) => ( - + {domIds.map((id) => ( + ))} diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index b47d344f2e..d21c8be111 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -22,7 +22,7 @@ import PronunciationsBackend from "components/Pronunciations/PronunciationsBacke import SenseCard from "components/WordCard/SenseCard"; import SummarySenseCard from "components/WordCard/SummarySenseCard"; import { TypographyWithFont } from "utilities/fontComponents"; -import { getLocalizedDateTimeString } from "utilities/utilities"; +import { friendlySep, getDateTimeString } from "utilities/utilities"; interface WordCardProps { languages?: string[]; @@ -46,7 +46,7 @@ export default function WordCard(props: WordCardProps): ReactElement { const { audio, editedBy, flag, id, note, senses } = word; const [full, setFull] = useState(false); const [username, setUsername] = useState(""); - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); useEffect(() => { if (provenance && editedBy?.length) { @@ -145,10 +145,7 @@ export default function WordCard(props: WordCardProps): ReactElement { {t("wordCard.wordId", { val: id })}
{t("wordCard.wordModified", { - val: getLocalizedDateTimeString( - word.modified, - i18n.resolvedLanguage - ), + val: getDateTimeString(word.modified, friendlySep), })} {!!username && ( <> diff --git a/src/utilities/utilities.ts b/src/utilities/utilities.ts index 0d002cfbf4..cbe0ec46c2 100644 --- a/src/utilities/utilities.ts +++ b/src/utilities/utilities.ts @@ -43,6 +43,12 @@ interface DateTimeSeparators { time?: string; } +export const friendlySep: DateTimeSeparators = { + date: "/", + dateTime: " ", + time: ":", +}; + const pathSep: DateTimeSeparators = { date: "-", dateTime: "_", @@ -72,21 +78,6 @@ export function getDateTimeString( return `${dateString}${sep?.dateTime ?? pathSep.dateTime}${timeString}`; } -export function getLocalizedDateTimeString( - utcString?: string, - lang?: string -): string { - return new Date(utcString ?? Date.now()).toLocaleString(lang, { - day: "numeric", - hour: "numeric", - hour12: true, - minute: "2-digit", - month: "short", - weekday: "short", - year: "numeric", - }); -} - // A general-purpose edit distance. export interface LevenshteinDistParams { delCost: number; From 43d78f57a9d92c7783f0b666b1fa0be95482240b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 18 Aug 2025 11:11:00 -0400 Subject: [PATCH 15/15] Consolidate text ids --- .../Cells/EditCell/EditDialog.tsx | 24 +++++++++---- .../Cells/EditCell/EditSenseDialog.tsx | 26 ++++++++++---- .../EditCell/tests/EditSenseDialog.test.tsx | 5 +-- .../ReviewEntriesTable/index.tsx | 36 +++++++++++++------ .../ReviewEntriesTable/tests/index.test.tsx | 9 +++-- 5 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx index 64563d8057..f969cd30b4 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx @@ -83,6 +83,16 @@ export enum EditDialogId { TextFieldVernacular = "edit-dialog-vernacular-textfield", } +export enum EditDialogTextId { + CardFlag = "reviewEntries.columns.flag", + CardNote = "reviewEntries.columns.note", + CardPronunciations = "reviewEntries.columns.pronunciations", + CardSenses = "reviewEntries.columns.senses", + CardVernacular = "reviewEntries.columns.vernacular", + DialogCancel = "reviewEntries.discardChanges", + TitlePrefix = "reviewEntries.columns.edit", +} + enum EditField { Flag, Note, @@ -333,12 +343,12 @@ export default function EditDialog(props: EditDialogProps): ReactElement { handleCancel={() => setCancelDialog(false)} handleConfirm={cancelAndClose} open={cancelDialog} - text="reviewEntries.discardChanges" + text={EditDialogTextId.DialogCancel} /> - {`${t("reviewEntries.columns.edit")} : ${props.word.vernacular}`} + {`${t(EditDialogTextId.TitlePrefix)} : ${props.word.vernacular}`}
{/* Vernacular */} - + ) } - title={t("reviewEntries.columns.senses")} + title={t(EditDialogTextId.CardSenses)} /> - + - + {newWord.flag.active ? ( diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx index c163ef8cdb..a87997353b 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx @@ -57,6 +57,16 @@ export enum EditSenseDialogId { TextFieldGlossPrefix = "edit-sense-gloss-textfield-", } +export enum EditSenseDialogTextId { + CardDefinitions = "reviewEntries.columns.definitions", + CardGlosses = "reviewEntries.columns.glosses", + CardPartOfSpeech = "reviewEntries.columns.partOfSpeech", + CardPartOfSpeechUnspecified = "grammaticalCategory.group.Unspecified", + CardSemanticDomains = "reviewEntries.columns.domains", + DialogCancel = "reviewEntries.discardChanges", + Title = "reviewEntries.editSense", +} + export enum EditSenseField { Definitions, Glosses, @@ -181,12 +191,12 @@ export default function EditSenseDialog( handleCancel={() => setCancelDialog(false)} handleConfirm={cancelAndClose} open={cancelDialog} - text="reviewEntries.discardChanges" + text={EditSenseDialogTextId.DialogCancel} /> - {t("reviewEntries.editSense")} + {t(EditSenseDialogTextId.Title)}
- + - + - + {newSense.grammaticalInfo.catGroup === GramCatGroup.Unspecified ? ( - {t("grammaticalCategory.group.Unspecified")} + {t(EditSenseDialogTextId.CardPartOfSpeechUnspecified)} ) : ( - + { }); describe("definitionsEnabled & grammaticalInfoEnabled", () => { - const definitionsTitle = "reviewEntries.columns.definitions"; - const partOfSpeechTitle = "reviewEntries.columns.partOfSpeech"; + const definitionsTitle = EditSenseDialogTextId.CardDefinitions; + const partOfSpeechTitle = EditSenseDialogTextId.CardPartOfSpeech; test("show definitions when definitionsEnabled is true", async () => { await renderEditSenseDialog(true, false); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx index 9976e047e8..1fbf5cdf8a 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx @@ -82,6 +82,20 @@ export enum ColumnId { Vernacular = "vernacular", } +export const ColumnHeaderTextId: Record = { + [ColumnId.Definitions]: "reviewEntries.columns.definitions", + [ColumnId.Domains]: "reviewEntries.columns.domains", + [ColumnId.Delete]: "reviewEntries.columns.delete", + [ColumnId.Edit]: "reviewEntries.columns.edit", + [ColumnId.Flag]: "reviewEntries.columns.flag", + [ColumnId.Glosses]: "reviewEntries.columns.glosses", + [ColumnId.Note]: "reviewEntries.columns.note", + [ColumnId.PartOfSpeech]: "reviewEntries.columns.partOfSpeech", + [ColumnId.Pronunciations]: "reviewEntries.columns.pronunciations", + [ColumnId.Senses]: "reviewEntries.columns.sensesCount", + [ColumnId.Vernacular]: "reviewEntries.columns.vernacular", +}; + // Constants for pagination state. const rowsPerPage = [10, 100]; const initPaginationState: MRT_PaginationState = { @@ -217,7 +231,7 @@ export default function ReviewEntriesTable(props: { ), enableHiding: false, Header: "", - header: t("reviewEntries.columns.edit"), + header: t(ColumnHeaderTextId[ColumnId.Edit]), id: ColumnId.Edit, size: IconColumnSize, visibleInShowHideMenu: false, @@ -229,7 +243,7 @@ export default function ReviewEntriesTable(props: { enableColumnOrdering: false, enableHiding: false, filterFn: ff.filterFnString, - header: t("reviewEntries.columns.vernacular"), + header: t(ColumnHeaderTextId[ColumnId.Vernacular]), id: ColumnId.Vernacular, size: BaselineColumnSize - 40, sortingFn: sf.sortingFnVernacular(vernLang), @@ -240,7 +254,7 @@ export default function ReviewEntriesTable(props: { enableFilterMatchHighlighting: false, filterFn: "equals", Header: #, - header: t("reviewEntries.columns.sensesCount"), + header: t(ColumnHeaderTextId[ColumnId.Senses]), id: ColumnId.Senses, muiTableHeadCellProps: { sx: { @@ -257,7 +271,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor((w) => w.senses.flatMap((s) => s.definitions), { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnDefinitions, - header: t("reviewEntries.columns.definitions"), + header: t(ColumnHeaderTextId[ColumnId.Definitions]), id: ColumnId.Definitions, size: BaselineColumnSize + 20, sortingFn: sf.sortingFnDefinitions, @@ -268,7 +282,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor((w) => w.senses.flatMap((s) => s.glosses), { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnGlosses, - header: t("reviewEntries.columns.glosses"), + header: t(ColumnHeaderTextId[ColumnId.Glosses]), id: ColumnId.Glosses, sortingFn: sf.sortingFnGlosses, }), @@ -285,7 +299,7 @@ export default function ReviewEntriesTable(props: { value: g, })), filterVariant: "select", - header: t("reviewEntries.columns.partOfSpeech"), + header: t(ColumnHeaderTextId[ColumnId.PartOfSpeech]), id: ColumnId.PartOfSpeech, sortingFn: sf.sortingFnPartOfSpeech, visibleInShowHideMenu: grammaticalInfoEnabled, @@ -295,7 +309,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor((w) => w.senses.flatMap((s) => s.semanticDomains), { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnDomains, - header: t("reviewEntries.columns.domains"), + header: t(ColumnHeaderTextId[ColumnId.Domains]), id: ColumnId.Domains, sortingFn: sf.sortingFnDomains, }), @@ -312,7 +326,7 @@ export default function ReviewEntriesTable(props: { ), - header: t("reviewEntries.columns.pronunciations"), + header: t(ColumnHeaderTextId[ColumnId.Pronunciations]), id: ColumnId.Pronunciations, muiTableHeadCellProps: { sx: { @@ -331,7 +345,7 @@ export default function ReviewEntriesTable(props: { columnHelper.accessor((w) => w.note.text || undefined, { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnString, - header: t("reviewEntries.columns.note"), + header: t(ColumnHeaderTextId[ColumnId.Note]), id: ColumnId.Note, size: BaselineColumnSize - 40, sortingFn: sf.sortingFnNote, @@ -342,7 +356,7 @@ export default function ReviewEntriesTable(props: { Cell: ({ row }: CellProps) => , filterFn: ff.filterFnFlag, Header: , - header: t("reviewEntries.columns.flag"), + header: t(ColumnHeaderTextId[ColumnId.Flag]), id: ColumnId.Flag, muiTableHeadCellProps: { sx: { @@ -365,7 +379,7 @@ export default function ReviewEntriesTable(props: { ), enableHiding: false, Header: "", - header: t("reviewEntries.columns.delete"), + header: t(ColumnHeaderTextId[ColumnId.Delete]), id: ColumnId.Delete, size: IconColumnSize, visibleInShowHideMenu: false, diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx index c06b0bfd4f..e0095a9df9 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx @@ -4,7 +4,10 @@ import { Provider } from "react-redux"; import configureMockStore from "redux-mock-store"; import { defaultState } from "components/Project/ProjectReduxTypes"; -import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesTable"; +import ReviewEntriesTable, { + ColumnHeaderTextId, + ColumnId, +} from "goals/ReviewEntries/ReviewEntriesTable"; import { mockWords, sortOrder, @@ -133,8 +136,8 @@ describe("ReviewEntriesTable", () => { }); describe("definitionsEnabled & grammaticalInfoEnabled", () => { - const defTextId = "reviewEntries.columns.definitions"; - const posTextId = "reviewEntries.columns.partOfSpeech"; + const defTextId = ColumnHeaderTextId[ColumnId.Definitions]; + const posTextId = ColumnHeaderTextId[ColumnId.PartOfSpeech]; test("show definitions when definitionsEnabled is true", async () => { await renderReviewEntriesTable(true, false);