From 3e61ac700f3c328aea99754693f4526396849a67 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Tue, 5 Dec 2023 08:42:09 -0500 Subject: [PATCH] Port ReviewEntries to use redux-toolkit (#2800) --- src/goals/DefaultGoal/BaseGoalScreen.tsx | 4 +- .../Redux/ReviewEntriesActions.ts | 111 +++++++++-------- .../Redux/ReviewEntriesReducer.ts | 70 +++++------ .../Redux/ReviewEntriesReduxTypes.ts | 45 +------ .../Redux/tests/ReviewEntriesActions.test.tsx | 116 ++++++++++++++++-- .../Redux/tests/ReviewEntriesReducer.test.tsx | 51 -------- .../CellComponents/DeleteCell.tsx | 11 +- .../ReviewEntriesTable/index.tsx | 13 +- src/goals/ReviewEntries/ReviewEntriesTypes.ts | 8 +- src/goals/ReviewEntries/index.tsx | 10 +- src/goals/ReviewEntries/tests/index.test.tsx | 17 ++- src/rootReducer.ts | 2 +- src/store.ts | 9 +- 13 files changed, 232 insertions(+), 235 deletions(-) delete mode 100644 src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx diff --git a/src/goals/DefaultGoal/BaseGoalScreen.tsx b/src/goals/DefaultGoal/BaseGoalScreen.tsx index 61338138be..dbcb5fd7f4 100644 --- a/src/goals/DefaultGoal/BaseGoalScreen.tsx +++ b/src/goals/DefaultGoal/BaseGoalScreen.tsx @@ -6,7 +6,7 @@ import PageNotFound from "components/PageNotFound/component"; import DisplayProgress from "goals/DefaultGoal/DisplayProgress"; import Loading from "goals/DefaultGoal/Loading"; import { clearTree } from "goals/MergeDuplicates/Redux/MergeDupsActions"; -import { clearReviewEntriesState } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; +import { resetReviewEntries } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { StoreState } from "types"; import { Goal, GoalStatus, GoalType } from "types/goals"; import { useAppDispatch, useAppSelector } from "types/hooks"; @@ -52,7 +52,7 @@ export function BaseGoalScreen(): ReactElement { useEffect(() => { return function cleanup(): void { dispatch(setCurrentGoal()); - dispatch(clearReviewEntriesState()); + dispatch(resetReviewEntries()); dispatch(clearTree()); }; }, [dispatch]); diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index dee559c3df..500072570c 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -1,4 +1,6 @@ -import { Sense } from "api/models"; +import { Action, PayloadAction } from "@reduxjs/toolkit"; + +import { Sense, Word } from "api/models"; import * as backend from "backend"; import { addEntryEditToGoal, @@ -6,12 +8,12 @@ import { } from "components/GoalTimeline/Redux/GoalActions"; import { uploadFileFromUrl } from "components/Pronunciations/utilities"; import { - ReviewClearReviewEntriesState, - ReviewEntriesActionTypes, - ReviewSortBy, - ReviewUpdateWord, - ReviewUpdateWords, -} from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; + deleteWordAction, + resetReviewEntriesAction, + setAllWordsAction, + setSortByAction, + updateWordAction, +} from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; import { ColumnId, ReviewEntriesSense, @@ -20,38 +22,45 @@ import { import { StoreStateDispatch } from "types/Redux/actions"; import { newNote, newSense } from "types/word"; -export function sortBy(columnId?: ColumnId): ReviewSortBy { - return { - type: ReviewEntriesActionTypes.SortBy, - sortBy: columnId, - }; +// Action Creation Functions + +export function deleteWord(wordId: string): Action { + return deleteWordAction(wordId); } -export function updateAllWords(words: ReviewEntriesWord[]): ReviewUpdateWords { - return { - type: ReviewEntriesActionTypes.UpdateAllWords, - words, - }; +export function resetReviewEntries(): Action { + return resetReviewEntriesAction(); +} + +export function setAllWords(words: Word[]): PayloadAction { + return setAllWordsAction(words); +} + +export function setSortBy(columnId?: ColumnId): PayloadAction { + return setSortByAction(columnId); } -function updateWord(oldId: string, updatedWord: ReviewEntriesWord) { +interface WordUpdate { + oldId: string; + updatedWord: Word; +} + +export function updateWord(update: WordUpdate): PayloadAction { + return updateWordAction(update); +} + +// Dispatch Functions + +/** Updates a word and the current goal. */ +function asyncUpdateWord(oldId: string, updatedWord: Word) { return async (dispatch: StoreStateDispatch) => { dispatch(addEntryEditToGoal({ newId: updatedWord.id, oldId })); await dispatch(asyncUpdateGoal()); - const update: ReviewUpdateWord = { - type: ReviewEntriesActionTypes.UpdateWord, - oldId, - updatedWord, - }; - dispatch(update); + dispatch(updateWord({ oldId, updatedWord })); }; } -export function clearReviewEntriesState(): ReviewClearReviewEntriesState { - return { type: ReviewEntriesActionTypes.ClearReviewEntriesState }; -} - -// Return the translation code for our error, or undefined if there is no error +/** Return the translation code for our error, or undefined if there is no error */ export function getSenseError( sense: ReviewEntriesSense, checkGlosses = true, @@ -66,10 +75,10 @@ export function getSenseError( return undefined; } -// Returns a cleaned array of senses ready to be saved (none with .deleted=true): -// * If a sense is marked as deleted or is utterly blank, it is removed -// * If a sense lacks gloss, return error -// * If the user attempts to delete all senses, return old senses with deleted senses removed +/** Returns a cleaned array of senses ready to be saved (none with .deleted=true): + * - If a sense is marked as deleted or is utterly blank, it is removed + * - If a sense lacks gloss, return error + * - If the user attempts to delete all senses, return old senses with deleted senses removed */ function cleanSenses( senses: ReviewEntriesSense[], oldSenses: ReviewEntriesSense[] @@ -111,10 +120,10 @@ function cleanSenses( return oldSenses.filter((s) => !s.deleted); } -// Clean the vernacular field of a word: -// * If all senses are deleted, reject -// * If there's no vernacular field, add in the vernacular of old field -// * If neither the word nor oldWord has a vernacular, reject +/** Clean the vernacular field of a word: + * - If all senses are deleted, reject + * - If there's no vernacular field, add in the vernacular of old field + * - If neither the word nor oldWord has a vernacular, reject */ function cleanWord( word: ReviewEntriesWord, oldWord: ReviewEntriesWord @@ -132,7 +141,7 @@ function cleanWord( return typeof senses === "string" ? senses : { ...word, vernacular, senses }; } -// Converts the ReviewEntriesWord into a Word to send to the backend +/** Converts the ReviewEntriesWord into a Word to send to the backend */ export function updateFrontierWord( newData: ReviewEntriesWord, oldData?: ReviewEntriesWord @@ -145,6 +154,7 @@ export function updateFrontierWord( if (typeof editSource === "string") { return Promise.reject(editSource); } + const oldId = editSource.id; // Set aside audio changes for last. const delAudio = oldData.audio.filter( @@ -155,7 +165,7 @@ export function updateFrontierWord( delete editSource.audioNew; // Get the original word, for updating. - const editWord = await backend.getWord(editSource.id); + const editWord = await backend.getWord(oldId); // Update the data. editWord.vernacular = editSource.vernacular; @@ -166,23 +176,22 @@ export function updateFrontierWord( editWord.flag = { ...editSource.flag }; // Update the word in the backend, and retrieve the id. - editSource.id = (await backend.updateWord(editWord)).id; + let newId = (await backend.updateWord(editWord)).id; // Add/remove audio. for (const url of addAudio) { - editSource.id = await uploadFileFromUrl(editSource.id, url); + newId = await uploadFileFromUrl(newId, url); } for (const fileName of delAudio) { - editSource.id = await backend.deleteAudio(editSource.id, fileName); + newId = await backend.deleteAudio(newId, fileName); } - editSource.audio = (await backend.getWord(editSource.id)).audio; - // Update the review entries word in the state. - await dispatch(updateWord(editWord.id, editSource)); + // Update the word in the state. + await dispatch(asyncUpdateWord(oldId, await backend.getWord(newId))); }; } -// Creates a Sense from a cleaned ReviewEntriesSense and array of old senses. +/** Creates a Sense from a cleaned ReviewEntriesSense and array of old senses. */ export function getSenseFromEditSense( editSense: ReviewEntriesSense, oldSenses: Sense[] @@ -199,15 +208,15 @@ export function getSenseFromEditSense( return sense; } -// Performs specified backend Word-updating function, then makes state ReviewEntriesWord-updating dispatch -function refreshWord( +/** Performs specified backend Word-updating function, then makes state ReviewEntriesWord-updating dispatch */ +function asyncRefreshWord( oldWordId: string, wordUpdater: (wordId: string) => Promise ) { return async (dispatch: StoreStateDispatch): Promise => { const newWordId = await wordUpdater(oldWordId); const word = await backend.getWord(newWordId); - await dispatch(updateWord(oldWordId, new ReviewEntriesWord(word))); + await dispatch(asyncUpdateWord(oldWordId, word)); }; } @@ -215,7 +224,7 @@ export function deleteAudio( wordId: string, fileName: string ): (dispatch: StoreStateDispatch) => Promise { - return refreshWord(wordId, (wordId: string) => + return asyncRefreshWord(wordId, (wordId: string) => backend.deleteAudio(wordId, fileName) ); } @@ -224,7 +233,7 @@ export function uploadAudio( wordId: string, audioFile: File ): (dispatch: StoreStateDispatch) => Promise { - return refreshWord(wordId, (wordId: string) => + return asyncRefreshWord(wordId, (wordId: string) => backend.uploadAudio(wordId, audioFile) ); } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts index bd0d75ff49..b104d272d5 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts @@ -1,40 +1,38 @@ -import { - defaultState, - ReviewEntriesAction, - ReviewEntriesActionTypes, - ReviewEntriesState, -} from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { createSlice } from "@reduxjs/toolkit"; -export const reviewEntriesReducer = ( - state: ReviewEntriesState = defaultState, //createStore() calls each reducer with undefined state - action: ReviewEntriesAction | StoreAction -): ReviewEntriesState => { - switch (action.type) { - case ReviewEntriesActionTypes.SortBy: - // Change which column is being sorted by - return { ...state, sortBy: action.sortBy }; +import { defaultState } from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; +import { StoreActionTypes } from "rootActions"; - case ReviewEntriesActionTypes.UpdateAllWords: - // Update the local words - return { ...state, words: action.words }; +const reviewEntriesSlice = createSlice({ + name: "reviewEntriesState", + initialState: defaultState, + reducers: { + deleteWordAction: (state, action) => { + state.words = state.words.filter((w) => w.id !== action.payload); + }, + resetReviewEntriesAction: () => defaultState, + setAllWordsAction: (state, action) => { + state.words = action.payload; + }, + setSortByAction: (state, action) => { + state.sortBy = action.payload; + }, + updateWordAction: (state, action) => { + state.words = state.words.map((w) => + w.id === action.payload.oldId ? action.payload.updatedWord : w + ); + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); - case ReviewEntriesActionTypes.UpdateWord: - // Update the word of specified id - return { - ...state, - words: state.words.map((w) => - w.id === action.oldId ? { ...action.updatedWord } : w - ), - }; +export const { + deleteWordAction, + resetReviewEntriesAction, + setAllWordsAction, + setSortByAction, + updateWordAction, +} = reviewEntriesSlice.actions; - case ReviewEntriesActionTypes.ClearReviewEntriesState: - return defaultState; - - case StoreActionTypes.RESET: - return defaultState; - - default: - return state; - } -}; +export default reviewEntriesSlice.reducer; diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts index 8df1d02619..9547cdd04d 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts @@ -1,51 +1,12 @@ -import { - ColumnId, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; - -export enum ReviewEntriesActionTypes { - SortBy = "SORT_BY", - UpdateAllWords = "UPDATE_ALL_WORDS", - UpdateWord = "UPDATE_WORD", - ClearReviewEntriesState = "CLEAR_REVIEW_ENTRIES_STATE", -} - -export interface ReviewSortBy { - type: ReviewEntriesActionTypes.SortBy; - sortBy?: ColumnId; -} - -export interface ReviewUpdateWords { - type: ReviewEntriesActionTypes.UpdateAllWords; - words: ReviewEntriesWord[]; -} - -export interface ReviewUpdateWord { - type: ReviewEntriesActionTypes.UpdateWord; - oldId: string; - updatedWord: ReviewEntriesWord; -} - -export interface ReviewClearReviewEntriesState { - type: ReviewEntriesActionTypes.ClearReviewEntriesState; -} - -export type ReviewEntriesAction = - | ReviewSortBy - | ReviewUpdateWords - | ReviewUpdateWord - | ReviewClearReviewEntriesState; +import { Word } from "api/models"; +import { ColumnId } from "goals/ReviewEntries/ReviewEntriesTypes"; export interface ReviewEntriesState { - words: ReviewEntriesWord[]; - isRecording: boolean; + words: Word[]; sortBy?: ColumnId; - wordBeingRecorded?: string; } export const defaultState: ReviewEntriesState = { words: [], - isRecording: false, sortBy: undefined, - wordBeingRecorded: undefined, }; diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx index b4d3ef1995..0fec2339af 100644 --- a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx +++ b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx @@ -1,16 +1,23 @@ -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { PreloadedState } from "redux"; import { Sense, Word } from "api/models"; +import { defaultState } from "components/App/DefaultState"; import { + deleteWord, getSenseError, getSenseFromEditSense, + resetReviewEntries, + setAllWords, + setSortBy, updateFrontierWord, + updateWord, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { + ColumnId, ReviewEntriesSense, ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { RootState, setupStore } from "store"; import { newSemanticDomain } from "types/semanticDomain"; import { newFlag, newGloss, newNote, newSense, newWord } from "types/word"; import { Bcp47Code } from "types/writingSystem"; @@ -22,19 +29,16 @@ function mockGetWordResolve(data: Word): void { } jest.mock("backend", () => ({ + deleteAudio: () => jest.fn(), getWord: (wordId: string) => mockGetWord(wordId), updateWord: (word: Word) => mockUpdateWord(word), -})); -jest.mock("backend/localStorage", () => ({ - getProjectId: jest.fn(), + uploadAudio: () => jest.fn(), })); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ addEntryEditToGoal: () => jest.fn(), asyncUpdateGoal: () => jest.fn(), })); -const mockStore = configureMockStore([thunk])(); - // Dummy strings, glosses, and domains. const commonGuid = "mockGuid"; const gloss0 = newGloss("gloss", Bcp47Code.En); @@ -42,6 +46,8 @@ const gloss0Es = newGloss("glossario", Bcp47Code.Es); const gloss1 = newGloss("infinite", Bcp47Code.En); const domain0 = newSemanticDomain("1", "Universe"); const domain1 = newSemanticDomain("8.3.3.2.1", "Shadow"); +const colId = ColumnId.Definitions; +const wordId = "mockId"; // Dummy sense and word creators. function sense0(): Sense { @@ -67,25 +73,111 @@ function mockFrontierWord(vernacular = "word"): Word { return { ...newWord(vernacular), guid: commonGuid, - id: "word", + id: wordId, senses: [sense0()], }; } function mockReviewEntriesWord(vernacular = "word"): ReviewEntriesWord { return { ...new ReviewEntriesWord(), - id: "word", + id: wordId, vernacular, senses: [new ReviewEntriesSense(sense0())], }; } +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; + beforeEach(() => { jest.clearAllMocks(); - mockStore.clearActions(); }); describe("ReviewEntriesActions", () => { + describe("Action Creation Functions", () => { + test("deleteWord", () => { + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { + sortBy: colId, + words: [mockFrontierWord()], + }, + }); + + store.dispatch(deleteWord(wordId)); + expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); + expect(store.getState().reviewEntriesState.words).toHaveLength(0); + }); + + test("resetReviewEntries", () => { + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { + sortBy: colId, + words: [mockFrontierWord()], + }, + }); + + store.dispatch(resetReviewEntries()); + expect(store.getState().reviewEntriesState.sortBy).toBeUndefined(); + expect(store.getState().reviewEntriesState.words).toHaveLength(0); + }); + + test("setAllWords", () => { + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { + sortBy: colId, + words: [], + }, + }); + + const frontier = [mockFrontierWord("wordA"), mockFrontierWord("wordB")]; + store.dispatch(setAllWords(frontier)); + expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); + expect(store.getState().reviewEntriesState.words).toHaveLength( + frontier.length + ); + }); + + test("setSortBy", () => { + const store = setupStore(persistedDefaultState); + + store.dispatch(setSortBy(colId)); + expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); + + store.dispatch(setSortBy()); + expect(store.getState().reviewEntriesState.sortBy).toBeUndefined(); + }); + + test("updateWord", () => { + const frontier: Word[] = [ + { ...mockFrontierWord(), id: "otherA" }, + mockFrontierWord(), + { ...mockFrontierWord(), id: "otherB" }, + ]; + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { sortBy: colId, words: frontier }, + }); + + const newVern = "updatedVern"; + const newId = "updatedId"; + const updatedWord: Word = { ...mockFrontierWord(newVern), id: newId }; + store.dispatch(updateWord({ oldId: wordId, updatedWord })); + + const { sortBy, words } = store.getState().reviewEntriesState; + expect(sortBy).toEqual(colId); + expect(words).toHaveLength(3); + expect(words.find((w) => w.id === wordId)).toBeUndefined(); + const newWord = words.find((w) => w.id === newId); + expect(newWord?.vernacular).toEqual(newVern); + }); + }); + describe("updateFrontierWord", () => { beforeEach(() => { mockUpdateWord.mockResolvedValue(newWord()); @@ -97,9 +189,11 @@ describe("ReviewEntriesActions", () => { newRevWord: ReviewEntriesWord, oldRevWord: ReviewEntriesWord ): Promise { - await mockStore.dispatch(updateFrontierWord(newRevWord, oldRevWord)); + const store = setupStore(); + await store.dispatch(updateFrontierWord(newRevWord, oldRevWord)); } function checkResultantData(newFrontierWord: Word): void { + expect(mockUpdateWord).toHaveBeenCalled(); expect(mockUpdateWord.mock.calls[0][0]).toEqual(newFrontierWord); } diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx deleted file mode 100644 index 0192c2a340..0000000000 --- a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesReducer.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -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/ReviewEntriesTable/CellComponents/DeleteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx index cc7490258a..dce5f70664 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx @@ -5,10 +5,9 @@ import { useTranslation } from "react-i18next"; import { deleteFrontierWord as deleteFromBackend } from "backend"; import { CancelConfirmDialog } from "components/Dialogs"; -import { updateAllWords } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; +import { deleteWord } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { StoreState } from "types"; -import { useAppDispatch, useAppSelector } from "types/hooks"; +import { useAppDispatch } from "types/hooks"; export const buttonId = (wordId: string): string => `row-${wordId}-delete`; export const buttonIdCancel = "delete-cancel"; @@ -20,9 +19,6 @@ interface DeleteCellProps { export default function DeleteCell(props: DeleteCellProps): ReactElement { const [dialogOpen, setDialogOpen] = useState(false); - const words = useAppSelector( - (state: StoreState) => state.reviewEntriesState.words - ); const dispatch = useAppDispatch(); const { t } = useTranslation(); @@ -31,8 +27,7 @@ export default function DeleteCell(props: DeleteCellProps): ReactElement { async function deleteFrontierWord(): Promise { await deleteFromBackend(word.id); - const updatedWords = words.filter((w) => w.id !== word.id); - dispatch(updateAllWords(updatedWords)); + dispatch(deleteWord(word.id)); handleClose(); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx index ca85451581..c0f2ebb518 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx @@ -1,5 +1,6 @@ import MaterialTable, { OrderByCollection } from "@material-table/core"; import { Typography } from "@mui/material"; +import { createSelector } from "@reduxjs/toolkit"; import { enqueueSnackbar } from "notistack"; import React, { ReactElement, createRef, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -47,9 +48,11 @@ const tableRef: React.RefObject = createRef(); export default function ReviewEntriesTable( props: ReviewEntriesTableProps ): ReactElement { - const words = useSelector( - (state: StoreState) => state.reviewEntriesState.words + const wordsSelector = createSelector( + [(state: StoreState) => state.reviewEntriesState.words], + (words) => words.map((w) => new ReviewEntriesWord(w)) ); + const allWords = useSelector(wordsSelector); const showDefinitions = useSelector( (state: StoreState) => state.currentProjectState.project.definitionsEnabled ); @@ -58,8 +61,8 @@ export default function ReviewEntriesTable( state.currentProjectState.project.grammaticalInfoEnabled ); const { t } = useTranslation(); - const [maxRows, setMaxRows] = useState(words.length); - const [pageState, setPageState] = useState(getPageState(words.length)); + const [maxRows, setMaxRows] = useState(allWords.length); + const [pageState, setPageState] = useState(getPageState(allWords.length)); const [scrollToTop, setScrollToTop] = useState(false); const updateMaxRows = (): void => { @@ -156,7 +159,7 @@ export default function ReviewEntriesTable( } columns={activeColumns} - data={words} + data={allWords} onFilterChange={updateMaxRows} onOrderCollectionChange={onOrderCollectionChange} onRowsPerPageChange={() => setScrollToTop(true)} diff --git a/src/goals/ReviewEntries/ReviewEntriesTypes.ts b/src/goals/ReviewEntries/ReviewEntriesTypes.ts index 2d1e96af61..b7a7be4ce8 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTypes.ts +++ b/src/goals/ReviewEntries/ReviewEntriesTypes.ts @@ -63,9 +63,7 @@ export class ReviewEntriesWord { /** Construct a ReviewEntriesWord from a Word. * Important: Some things (e.g., note language) aren't preserved! */ constructor(word?: Word, analysisLang?: string) { - if (!word) { - word = newWord(); - } + word ??= newWord(); this.id = word.id; this.vernacular = word.vernacular; this.senses = word.senses.map( @@ -91,9 +89,7 @@ export class ReviewEntriesSense { * Important: Some things aren't preserved! * (E.g., distinct glosses with the same language are combined.) */ constructor(sense?: Sense, analysisLang?: string) { - if (!sense) { - sense = newSense(); - } + sense ??= newSense(); this.guid = sense.guid; this.definitions = analysisLang ? sense.definitions.filter((d) => d.language === analysisLang) diff --git a/src/goals/ReviewEntries/index.tsx b/src/goals/ReviewEntries/index.tsx index 407aebcab0..f51880fe3c 100644 --- a/src/goals/ReviewEntries/index.tsx +++ b/src/goals/ReviewEntries/index.tsx @@ -2,8 +2,8 @@ import { ReactElement, useEffect, useState } from "react"; import { getFrontierWords } from "backend"; import { - sortBy, - updateAllWords, + setAllWords, + setSortBy, updateFrontierWord, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import ReviewEntriesCompleted from "goals/ReviewEntries/ReviewEntriesCompleted"; @@ -25,11 +25,11 @@ export default function ReviewEntries(props: ReviewEntriesProps): ReactElement { useEffect(() => { if (!props.completed) { getFrontierWords().then((frontier) => { - dispatch(updateAllWords(frontier.map((w) => new ReviewEntriesWord(w)))); + dispatch(setAllWords(frontier)); setLoaded(true); }); } - }, [dispatch, props]); + }, [dispatch, props.completed]); return props.completed ? ( @@ -38,7 +38,7 @@ export default function ReviewEntries(props: ReviewEntriesProps): ReactElement { onRowUpdate={(newData: ReviewEntriesWord, oldData?: ReviewEntriesWord) => dispatch(updateFrontierWord(newData, oldData)) } - onSort={(columnId?: ColumnId) => dispatch(sortBy(columnId))} + onSort={(columnId?: ColumnId) => dispatch(setSortBy(columnId))} /> ) : (
diff --git a/src/goals/ReviewEntries/tests/index.test.tsx b/src/goals/ReviewEntries/tests/index.test.tsx index 483d22f50e..9bacb207ec 100644 --- a/src/goals/ReviewEntries/tests/index.test.tsx +++ b/src/goals/ReviewEntries/tests/index.test.tsx @@ -7,10 +7,7 @@ import "tests/reactI18nextMock"; import ReviewEntries from "goals/ReviewEntries"; import * as actions from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { - ReviewEntriesWord, - wordFromReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; +import { wordFromReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; import mockWords from "goals/ReviewEntries/tests/WordsMock"; import { defaultWritingSystem } from "types/writingSystem"; @@ -48,7 +45,7 @@ jest.mock("types/hooks", () => ({ useAppDispatch: () => jest.fn(), })); -const updateAllWordsSpy = jest.spyOn(actions, "updateAllWords"); +const setAllWordsSpy = jest.spyOn(actions, "setAllWords"); // Mock store + axios const mockReviewEntryWords = mockWords(); @@ -60,7 +57,9 @@ const state = { vernacularWritingSystem: defaultWritingSystem, }, }, - reviewEntriesState: { words: mockReviewEntryWords }, + reviewEntriesState: { + words: mockReviewEntryWords.map(wordFromReviewEntriesWord), + }, treeViewState: { open: false, currentDomain: { id: "number", name: "domain", subdomains: [] }, @@ -96,10 +95,8 @@ beforeEach(async () => { describe("ReviewEntries", () => { it("Initializes correctly", () => { - expect(updateAllWordsSpy).toHaveBeenCalled(); - const wordIds = updateAllWordsSpy.mock.calls[0][0].map( - (w: ReviewEntriesWord) => w.id - ); + expect(setAllWordsSpy).toHaveBeenCalled(); + const wordIds = setAllWordsSpy.mock.calls[0][0].map((w) => w.id); expect(wordIds).toHaveLength(mockReviewEntryWords.length); mockReviewEntryWords.forEach((w) => expect(wordIds).toContain(w.id)); }); diff --git a/src/rootReducer.ts b/src/rootReducer.ts index 98fb02edbf..edb50d192b 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -8,7 +8,7 @@ import pronunciationsReducer from "components/Pronunciations/Redux/Pronunciation import treeViewReducer from "components/TreeView/Redux/TreeViewReducer"; import characterInventoryReducer from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import mergeDupStepReducer from "goals/MergeDuplicates/Redux/MergeDupsReducer"; -import { reviewEntriesReducer } from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; +import reviewEntriesReducer from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; import { StoreState } from "types"; import analyticsReducer from "types/Redux/analytics"; diff --git a/src/store.ts b/src/store.ts index 19bf53b063..c635c57e6d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,14 +9,9 @@ const persistConfig = { key: "root", storage }; const persistedReducer = persistReducer(persistConfig, rootReducer); -// To enable the immutability checks for the Redux Reducers, -// set REACT_APP_IMMUTABLE_CHECK to 1 in .env.development.local -// (in the project's root folder) +// In development and test, immutability check enabled for the Redux reducers const immutableCheckConfig = - process.env.NODE_ENV === "development" && - process.env.REACT_APP_IMMUTABLE_CHECK === "1" - ? { warnAfter: 1000 } - : false; + process.env.NODE_ENV !== "production" ? { warnAfter: 1000 } : false; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const setupStore = (preloadedState?: PreloadedState) => {