diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 392a527094..8817cd415b 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -321,6 +321,9 @@ "search": "Search" } }, + "completed": { + "number": "Number of entries edited: " + }, "undo": { "undo": "Undo Edit", "undoDialog": "Undo this edit?", diff --git a/src/components/GoalTimeline/GoalList.tsx b/src/components/GoalTimeline/GoalList.tsx index d576bab38b..6817aef2a5 100644 --- a/src/components/GoalTimeline/GoalList.tsx +++ b/src/components/GoalTimeline/GoalList.tsx @@ -12,6 +12,8 @@ import { CharInvChangesGoalList } from "goals/CharacterInventory/CharInvComplete import { CharInvChanges } from "goals/CharacterInventory/CharacterInventoryTypes"; import { MergesCount } from "goals/MergeDuplicates/MergeDupsCompleted"; import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; +import { EditsCount } from "goals/ReviewEntries/ReviewEntriesCompleted"; +import { EntriesEdited } from "goals/ReviewEntries/ReviewEntriesTypes"; import { Goal, GoalStatus, GoalType } from "types/goals"; type Orientation = "horizontal" | "vertical"; @@ -120,7 +122,8 @@ function GoalTile(props: GoalTileProps): ReactElement { (goal.status === GoalStatus.Completed && goal.goalType !== GoalType.CreateCharInv && goal.goalType !== GoalType.MergeDups && - goal.goalType !== GoalType.ReviewDeferredDups) + goal.goalType !== GoalType.ReviewDeferredDups && + goal.goalType !== GoalType.ReviewEntries) } data-testid="goal-button" > @@ -161,6 +164,8 @@ function getCompletedGoalInfo(goal: Goal): ReactElement { case GoalType.MergeDups: case GoalType.ReviewDeferredDups: return MergesCount(goal.changes as MergesCompleted); + case GoalType.ReviewEntries: + return EditsCount(goal.changes as EntriesEdited); default: return ; } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 089cc8e6a1..dee559c3df 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -1,6 +1,9 @@ import { Sense } from "api/models"; import * as backend from "backend"; -import { addEntryEditToGoal } from "components/GoalTimeline/Redux/GoalActions"; +import { + addEntryEditToGoal, + asyncUpdateGoal, +} from "components/GoalTimeline/Redux/GoalActions"; import { uploadFileFromUrl } from "components/Pronunciations/utilities"; import { ReviewClearReviewEntriesState, @@ -32,8 +35,9 @@ export function updateAllWords(words: ReviewEntriesWord[]): ReviewUpdateWords { } function updateWord(oldId: string, updatedWord: ReviewEntriesWord) { - return (dispatch: StoreStateDispatch) => { + return async (dispatch: StoreStateDispatch) => { dispatch(addEntryEditToGoal({ newId: updatedWord.id, oldId })); + await dispatch(asyncUpdateGoal()); const update: ReviewUpdateWord = { type: ReviewEntriesActionTypes.UpdateWord, oldId, @@ -174,7 +178,7 @@ export function updateFrontierWord( editSource.audio = (await backend.getWord(editSource.id)).audio; // Update the review entries word in the state. - dispatch(updateWord(editWord.id, editSource)); + await dispatch(updateWord(editWord.id, editSource)); }; } @@ -203,7 +207,7 @@ function refreshWord( return async (dispatch: StoreStateDispatch): Promise => { const newWordId = await wordUpdater(oldWordId); const word = await backend.getWord(newWordId); - dispatch(updateWord(oldWordId, new ReviewEntriesWord(word))); + await dispatch(updateWord(oldWordId, new ReviewEntriesWord(word))); }; } diff --git a/src/goals/ReviewEntries/tests/ReviewEntriesActions.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx similarity index 98% rename from src/goals/ReviewEntries/tests/ReviewEntriesActions.test.tsx rename to src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx index bf62285273..b4d3ef1995 100644 --- a/src/goals/ReviewEntries/tests/ReviewEntriesActions.test.tsx +++ b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx @@ -25,10 +25,13 @@ jest.mock("backend", () => ({ getWord: (wordId: string) => mockGetWord(wordId), updateWord: (word: Word) => mockUpdateWord(word), })); - jest.mock("backend/localStorage", () => ({ getProjectId: jest.fn(), })); +jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ + addEntryEditToGoal: () => jest.fn(), + asyncUpdateGoal: () => jest.fn(), +})); const mockStore = configureMockStore([thunk])(); diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx new file mode 100644 index 0000000000..0192c2a340 --- /dev/null +++ b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx @@ -0,0 +1,51 @@ +import { reviewEntriesReducer } from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; +import { + defaultState, + ReviewEntriesActionTypes, +} from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; +import { + ReviewEntriesSense, + ReviewEntriesWord, +} from "goals/ReviewEntries/ReviewEntriesTypes"; + +describe("ReviewEntriesReducer", () => { + it("Returns default state when passed undefined state", () => { + expect(reviewEntriesReducer(undefined, { type: undefined } as any)).toEqual( + defaultState + ); + }); + + it("Adds a set of words to a list when passed an UpdateAllWords action", () => { + const revWords = [new ReviewEntriesWord(), new ReviewEntriesWord()]; + const state = reviewEntriesReducer(defaultState, { + type: ReviewEntriesActionTypes.UpdateAllWords, + words: revWords, + }); + expect(state).toEqual({ ...defaultState, words: revWords }); + }); + + it("Updates a specified word when passed an UpdateWord action", () => { + const oldId = "id-of-word-to-be-updated"; + const oldWords: ReviewEntriesWord[] = [ + { ...new ReviewEntriesWord(), id: "other-id" }, + { ...new ReviewEntriesWord(), id: oldId, vernacular: "old-vern" }, + ]; + const oldState = { ...defaultState, words: oldWords }; + + const newId = "id-after-update"; + const newRevWord: ReviewEntriesWord = { + ...new ReviewEntriesWord(), + id: newId, + vernacular: "new-vern", + senses: [{ ...new ReviewEntriesSense(), guid: "new-sense-id" }], + }; + const newWords = [oldWords[0], newRevWord]; + + const newState = reviewEntriesReducer(oldState, { + type: ReviewEntriesActionTypes.UpdateWord, + oldId, + updatedWord: newRevWord, + }); + expect(newState).toEqual({ ...oldState, words: newWords }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx b/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx index e179150888..d612c0d1fe 100644 --- a/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesCompleted.tsx @@ -24,15 +24,23 @@ export default function ReviewEntriesCompleted(): ReactElement { {t("reviewEntries.title")} - - {t("reviewEntries.completed.number")} - {changes.entryEdits?.length ?? 0} - + {EditsCount(changes)} {changes.entryEdits?.map((e) => )} ); } +export function EditsCount(changes: EntriesEdited): ReactElement { + const { t } = useTranslation(); + + return ( + + {t("reviewEntries.completed.number")} + {changes.entryEdits?.length ?? 0} + + ); +} + function EditedEntry(props: { edit: EntryEdit }): ReactElement { return ( @@ -82,7 +90,7 @@ function UndoButton(props: UndoButtonProps): ReactElement { return isUndoEnabled ? ( - <> +
) : ( - <> +
- +
); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTypes.ts b/src/goals/ReviewEntries/ReviewEntriesTypes.ts index 0b06f81886..7bcd8b190a 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTypes.ts +++ b/src/goals/ReviewEntries/ReviewEntriesTypes.ts @@ -9,7 +9,7 @@ import { Word, } from "api/models"; import { Goal, GoalName, GoalType } from "types/goals"; -import { newSense, newWord } from "types/word"; +import { newNote, newSense, newWord } from "types/word"; import { cleanDefinitions, cleanGlosses } from "utilities/wordUtilities"; export enum ColumnId { @@ -113,3 +113,35 @@ export class ReviewEntriesSense { return sense.glosses.map((g) => g.def).join(ReviewEntriesSense.SEPARATOR); } } + +/** Reverse map of the ReviewEntriesSense constructor. + * Important: Not everything is preserved! */ +function senseFromReviewEntriesSense(revSense: ReviewEntriesSense): Sense { + return { + ...newSense(), + accessibility: revSense.protected + ? Status.Protected + : revSense.deleted + ? Status.Deleted + : Status.Active, + definitions: revSense.definitions.map((d) => ({ ...d })), + glosses: revSense.glosses.map((g) => ({ ...g })), + grammaticalInfo: revSense.partOfSpeech, + guid: revSense.guid, + semanticDomains: revSense.domains.map((dom) => ({ ...dom })), + }; +} + +/** Reverse map of the ReviewEntriesWord constructor. + * Important: Not everything is preserved! */ +export function wordFromReviewEntriesWord(revWord: ReviewEntriesWord): Word { + return { + ...newWord(revWord.vernacular), + accessibility: revWord.protected ? Status.Protected : Status.Active, + audio: [...revWord.audio], + id: revWord.id, + flag: { ...revWord.flag }, + note: newNote(revWord.noteText), + senses: revWord.senses.map(senseFromReviewEntriesSense), + }; +} diff --git a/src/goals/ReviewEntries/tests/ReviewEntriesReducer.test.tsx b/src/goals/ReviewEntries/tests/ReviewEntriesReducer.test.tsx deleted file mode 100644 index b9873b3331..0000000000 --- a/src/goals/ReviewEntries/tests/ReviewEntriesReducer.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { reviewEntriesReducer } from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; -import { - defaultState, - ReviewEntriesActionTypes, -} from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; -import { - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; -import { newSemanticDomain } from "types/semanticDomain"; -import { Bcp47Code } from "types/writingSystem"; - -const mockState = { - ...defaultState, - words: mockWords(), -}; -const reviewEntriesWord: ReviewEntriesWord = { - ...new ReviewEntriesWord(), - id: mockState.words[0].id, - vernacular: "toadTOAD", - senses: [ - { - ...new ReviewEntriesSense(), - guid: "1", - glosses: [{ def: "bupBUP", language: Bcp47Code.En }], - domains: [ - newSemanticDomain("number", "domain"), - newSemanticDomain("number2", "domain2"), - ], - }, - ], -}; -const result: ReviewEntriesWord = { - ...new ReviewEntriesWord(), - id: "a new mock id", - vernacular: "toadTOAD", - senses: [ - { - ...new ReviewEntriesSense(), - guid: "1", - glosses: [{ def: "bupBUP", language: Bcp47Code.En }], - domains: [ - newSemanticDomain("number", "domain"), - newSemanticDomain("number2", "domain2"), - ], - }, - ], -}; - -describe("ReviewEntriesReducer", () => { - it("Returns default state when passed undefined state", () => { - expect(reviewEntriesReducer(undefined, { type: undefined } as any)).toEqual( - defaultState - ); - }); - - it("Adds a set of words to a list when passed an UpdateAllWords action", () => { - expect( - reviewEntriesReducer(defaultState, { - type: ReviewEntriesActionTypes.UpdateAllWords, - words: mockWords(), - }) - ).toEqual(mockState); - }); - - it("Updates a specified word when passed an UpdateWord action", () => { - expect( - reviewEntriesReducer(mockState, { - type: ReviewEntriesActionTypes.UpdateWord, - oldId: mockWords()[0].id, - updatedWord: { ...reviewEntriesWord, id: result.id }, - }) - ).toEqual({ ...mockState, words: [result, mockWords()[1]] }); - }); -}); diff --git a/src/goals/ReviewEntries/tests/WordsMock.ts b/src/goals/ReviewEntries/tests/WordsMock.ts index 01a2bfbca9..45d65b8ecc 100644 --- a/src/goals/ReviewEntries/tests/WordsMock.ts +++ b/src/goals/ReviewEntries/tests/WordsMock.ts @@ -1,17 +1,10 @@ -import { GramCatGroup, Sense, Word } from "api/models"; +import { GramCatGroup } from "api/models"; import { ReviewEntriesSense, ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesTypes"; import { newSemanticDomain } from "types/semanticDomain"; -import { - newDefinition, - newFlag, - newGloss, - newNote, - newSense, - newWord, -} from "types/word"; +import { newDefinition, newFlag, newGloss } from "types/word"; import { Bcp47Code } from "types/writingSystem"; export default function mockWords(): ReviewEntriesWord[] { @@ -57,24 +50,3 @@ export default function mockWords(): ReviewEntriesWord[] { }, ]; } - -export function mockCreateWord(word: ReviewEntriesWord): Word { - return { - ...newWord(word.vernacular), - id: word.id, - senses: word.senses.map((sense) => createMockSense(sense)), - note: newNote(word.noteText), - flag: word.flag, - }; -} - -function createMockSense(sense: ReviewEntriesSense): Sense { - return { - ...newSense(), - guid: sense.guid, - definitions: [...sense.definitions], - glosses: [...sense.glosses], - grammaticalInfo: sense.partOfSpeech, - semanticDomains: [...sense.domains], - }; -} diff --git a/src/goals/ReviewEntries/tests/index.test.tsx b/src/goals/ReviewEntries/tests/index.test.tsx index 7590959b91..ab346fade8 100644 --- a/src/goals/ReviewEntries/tests/index.test.tsx +++ b/src/goals/ReviewEntries/tests/index.test.tsx @@ -7,8 +7,11 @@ import "tests/reactI18nextMock"; import ReviewEntries from "goals/ReviewEntries"; import * as actions from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; -import mockWords, { mockCreateWord } from "goals/ReviewEntries/tests/WordsMock"; +import { + ReviewEntriesWord, + wordFromReviewEntriesWord, +} from "goals/ReviewEntries/ReviewEntriesTypes"; +import mockWords from "goals/ReviewEntries/tests/WordsMock"; import { defaultWritingSystem } from "types/writingSystem"; const mockGetFrontierWords = jest.fn(); @@ -33,16 +36,16 @@ jest.mock("notistack", () => ({ ...jest.requireActual("notistack"), enqueueSnackbar: jest.fn(), })); -jest.mock("uuid", () => ({ v4: () => mockUuid() })); +jest.mock("uuid", () => ({ + v4: () => mockUuid(), +})); jest.mock("backend", () => ({ getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), })); // Mock the node module used by AudioRecorder. jest.mock("components/Pronunciations/Recorder"); jest.mock("components/TreeView", () => "div"); -jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ - addEntryEditToGoal: () => jest.fn(), -})); +jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({})); jest.mock("types/hooks", () => ({ useAppDispatch: () => jest.fn(), })); @@ -70,7 +73,7 @@ const mockStore = configureMockStore()(state); function setMockFunctions(): void { jest.clearAllMocks(); mockGetFrontierWords.mockResolvedValue( - mockReviewEntryWords.map(mockCreateWord) + mockReviewEntryWords.map(wordFromReviewEntriesWord) ); mockMaterialTable.mockReturnValue(Fragment); }