From e79ca3a37a95599659c864537fa59c3f5e35734d Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Thu, 21 Mar 2024 16:59:03 -0400 Subject: [PATCH] [CharInv] Consolidate find-and-replace pieces (#2964) --- .../Buttons/DeleteButtonWithDialog.tsx | 2 +- src/components/Buttons/UndoButton.tsx | 2 +- .../EntryCellComponents/DeleteEntry.tsx | 2 +- .../Dialogs/CancelConfirmDialog.tsx | 4 +- .../CancelConfirmDialogCollection.tsx | 10 +-- .../index.tsx => FindAndReplace.tsx} | 25 ++++-- .../FindAndReplace/CharacterReplaceDialog.tsx | 82 ------------------- .../FindAndReplace/FindAndReplaceActions.ts | 25 ------ .../CharacterDetail/tests/index.test.tsx | 13 ++- .../Redux/CharacterInventoryActions.ts | 61 +++++++++++--- .../MergeDupsStep/MergeDragDrop/index.tsx | 2 +- 11 files changed, 84 insertions(+), 144 deletions(-) rename src/goals/CharacterInventory/CharInv/CharacterDetail/{FindAndReplace/index.tsx => FindAndReplace.tsx} (78%) delete mode 100644 src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog.tsx delete mode 100644 src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions.ts diff --git a/src/components/Buttons/DeleteButtonWithDialog.tsx b/src/components/Buttons/DeleteButtonWithDialog.tsx index 472e1c1bb4..31af43f346 100644 --- a/src/components/Buttons/DeleteButtonWithDialog.tsx +++ b/src/components/Buttons/DeleteButtonWithDialog.tsx @@ -38,7 +38,7 @@ export default function DeleteButtonWithDialog( handleCancel={() => setOpen(false)} handleConfirm={handleConfirm} open={open} - textId={props.textId} + text={props.textId} /> ); diff --git a/src/components/Buttons/UndoButton.tsx b/src/components/Buttons/UndoButton.tsx index f5b3c2cff6..402eb146f3 100644 --- a/src/components/Buttons/UndoButton.tsx +++ b/src/components/Buttons/UndoButton.tsx @@ -42,7 +42,7 @@ export default function UndoButton(props: UndoButtonProps): ReactElement { setUndoDialogOpen(false)} handleConfirm={() => props.undo().then(() => setUndoDialogOpen(false)) diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx index 7ab950514c..dcb2976e2a 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/DeleteEntry.tsx @@ -46,7 +46,7 @@ export default function DeleteEntry(props: DeleteEntryProps): ReactElement { setOpen(false)} handleConfirm={() => { setOpen(false); diff --git a/src/components/Dialogs/CancelConfirmDialog.tsx b/src/components/Dialogs/CancelConfirmDialog.tsx index 42936c2abc..7b7fea21b4 100644 --- a/src/components/Dialogs/CancelConfirmDialog.tsx +++ b/src/components/Dialogs/CancelConfirmDialog.tsx @@ -13,7 +13,7 @@ import LoadingButton from "components/Buttons/LoadingButton"; interface CancelConfirmDialogProps { open: boolean; - textId: string; + text: string | ReactElement; handleCancel: () => void; handleConfirm: () => Promise | void; buttonIdCancel?: string; @@ -46,7 +46,7 @@ export default function CancelConfirmDialog( - {t(props.textId)} + {typeof props.text === "string" ? t(props.text) : props.text} diff --git a/src/components/ProjectUsers/CancelConfirmDialogCollection.tsx b/src/components/ProjectUsers/CancelConfirmDialogCollection.tsx index bab9ea753c..4e271f1386 100644 --- a/src/components/ProjectUsers/CancelConfirmDialogCollection.tsx +++ b/src/components/ProjectUsers/CancelConfirmDialogCollection.tsx @@ -173,7 +173,7 @@ export default function CancelConfirmDialogCollection( <> setRemoveUser(false)} handleConfirm={() => removeUser(props.userId)} buttonIdCancel={`${idRemoveUser}-cancel`} @@ -181,7 +181,7 @@ export default function CancelConfirmDialogCollection( /> setMakeHarvester(false)} handleConfirm={() => makeHarvester(props.userId)} buttonIdCancel={`${idHarvester}-cancel`} @@ -189,7 +189,7 @@ export default function CancelConfirmDialogCollection( /> setMakeEditor(false)} handleConfirm={() => makeEditor(props.userId)} buttonIdCancel={`${idEditor}-cancel`} @@ -197,7 +197,7 @@ export default function CancelConfirmDialogCollection( /> setMakeAdmin(false)} handleConfirm={() => makeAdmin(props.userId)} buttonIdCancel={`${idAdmin}-cancel`} @@ -205,7 +205,7 @@ export default function CancelConfirmDialogCollection( /> setMakeOwner(false)} handleConfirm={() => makeOwner(props.userId)} buttonIdCancel={`${idOwner}-cancel`} diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/index.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace.tsx similarity index 78% rename from src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/index.tsx rename to src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace.tsx index c7b8ebba8e..bf1cc71b72 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/index.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace.tsx @@ -1,10 +1,10 @@ import { Button, Typography } from "@mui/material"; -import { ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; -import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; -import { findAndReplace } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions"; +import CancelConfirmDialog from "components/Dialogs/CancelConfirmDialog"; +import { findAndReplace } from "goals/CharacterInventory/Redux/CharacterInventoryActions"; import { useAppDispatch } from "types/hooks"; import { TextFieldWithFont } from "utilities/fontComponents"; @@ -19,6 +19,8 @@ interface FindAndReplaceProps { initialFindValue: string; } +/** Component for replacing one character (every occurrence of it in the vernacular form + * of a word in the project) with another character. */ export default function FindAndReplace( props: FindAndReplaceProps ): ReactElement { @@ -46,6 +48,14 @@ export default function FindAndReplace( setWarningDialogOpen(false); }; + const dialogText = ( + <> + {t("charInventory.characterSet.replaceAll", { val: findValue })} +
+ {t("charInventory.characterSet.replaceAllWith", { val: replaceValue })} + + ); + return ( <> @@ -80,14 +90,13 @@ export default function FindAndReplace( > {t("charInventory.characterSet.apply")} - setWarningDialogOpen(false)} handleConfirm={dispatchFindAndReplace} - idCancel={buttonIdCancel} - idConfirm={buttonIdConfirm} + buttonIdCancel={buttonIdCancel} + buttonIdConfirm={buttonIdConfirm} /> ); diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog.tsx deleted file mode 100644 index 002e5e39a5..0000000000 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; -import { ReactElement, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { LoadingButton } from "components/Buttons"; - -interface ReplaceDialogProps { - open: boolean; - dialogFindValue: string; - dialogReplaceValue: string; - handleCancel: () => void; - handleConfirm: () => Promise; - idCancel?: string; - idConfirm?: string; -} - -/** - * Dialog to confirm replacement - */ -export default function CharacterReplaceDialog( - props: ReplaceDialogProps -): ReactElement { - const [loading, setLoading] = useState(false); - const { t } = useTranslation(); - - async function submitFindAndReplace(): Promise { - setLoading(true); - await props.handleConfirm(); - setLoading(false); - } - - return ( - - - {t("buttons.proceedWithCaution")} - - - - {t("charInventory.characterSet.replaceAll", { - val: props.dialogFindValue, - })} -
- {t("charInventory.characterSet.replaceAllWith", { - val: props.dialogReplaceValue, - })} -
-
- - - - {t("buttons.confirm")} - - -
- ); -} diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions.ts b/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions.ts deleted file mode 100644 index 356a8080ea..0000000000 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as backend from "backend"; -import { - fetchWords, - getAllCharacters, -} from "goals/CharacterInventory/Redux/CharacterInventoryActions"; -import { StoreStateDispatch } from "types/Redux/actions"; - -export function findAndReplace(findValue: string, replaceValue: string) { - return async (dispatch: StoreStateDispatch) => { - const allWords = await backend.getFrontierWords(); - const changedWords = allWords.filter((word) => - word.vernacular.includes(findValue) - ); - const findRegExp = new RegExp( - findValue.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"), - "g" - ); - for (const word of changedWords) { - word.vernacular = word.vernacular.replace(findRegExp, replaceValue); - await backend.updateWord(word); - } - await dispatch(fetchWords()); - await dispatch(getAllCharacters()); - }; -} diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index 27bab27239..5c8ab037f6 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -2,13 +2,13 @@ import { Provider } from "react-redux"; import { type ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; +import CancelConfirmDialog from "components/Dialogs/CancelConfirmDialog"; import CharacterDetail from "goals/CharacterInventory/CharInv/CharacterDetail"; import { buttonIdCancel, buttonIdConfirm, buttonIdSubmit, } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace"; -import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { type StoreState } from "types"; import { testInstanceHasText } from "utilities/testRendererUtilities"; @@ -22,12 +22,9 @@ jest.mock("@mui/material", () => { }; }); -jest.mock( - "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/FindAndReplaceActions", - () => ({ - findAndReplace: () => mockFindAndReplace(), - }) -); +jest.mock("goals/CharacterInventory/Redux/CharacterInventoryActions", () => ({ + findAndReplace: () => mockFindAndReplace(), +})); jest.mock("types/hooks", () => { return { ...jest.requireActual("types/hooks"), @@ -72,7 +69,7 @@ describe("CharacterDetail", () => { describe("FindAndReplace", () => { it("has working dialog", async () => { - const dialog = charMaster.root.findByType(CharacterReplaceDialog); + const dialog = charMaster.root.findByType(CancelConfirmDialog); const submitButton = charMaster.root.findByProps({ id: buttonIdSubmit }); const cancelButton = charMaster.root.findByProps({ id: buttonIdCancel }); const confButton = charMaster.root.findByProps({ id: buttonIdConfirm }); diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index 56d02e5a6c..4aeed54143 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -1,12 +1,12 @@ -import { Action, PayloadAction } from "@reduxjs/toolkit"; +import { type Action, type PayloadAction } from "@reduxjs/toolkit"; -import { Project } from "api/models"; -import { getFrontierWords } from "backend"; +import { type Project } from "api/models"; +import { getFrontierWords, updateWord } from "backend"; import router from "browserRouter"; import { asyncUpdateCurrentProject } from "components/Project/ProjectActions"; import { + type CharacterChange, CharacterStatus, - CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; import { addRejectedCharacterAction, @@ -19,16 +19,16 @@ import { setValidCharactersAction, } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { - CharacterInventoryState, - CharacterSetEntry, + type CharacterInventoryState, + type CharacterSetEntry, getCharacterStatus, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { addCharInvChangesToGoal, asyncUpdateGoal, } from "goals/Redux/GoalActions"; -import { StoreState } from "types"; -import { StoreStateDispatch } from "types/Redux/actions"; +import { type StoreState } from "types"; +import { type StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; // Action Creation Functions @@ -69,6 +69,8 @@ export function setValidCharacters(chars: string[]): PayloadAction { // Dispatch Functions +/** Returns a dispatch function to: update the in-state `.validCharacters` and + * `.rejectedCharacters` according to the given character and status. */ export function setCharacterStatus(character: string, status: CharacterStatus) { return (dispatch: StoreStateDispatch, getState: () => StoreState) => { switch (status) { @@ -97,7 +99,7 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { }; } -/** Sends the in-state character inventory to the server. */ +/** Returns a dispatch function to: send the in-state char inventory to the server. */ export function uploadInventory() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { const charInvState = getState().characterInventoryState; @@ -120,6 +122,8 @@ export function uploadInventory() { }; } +/** Returns a dispatch function to: fetch the current project's frontier and, from those + * words, update the array of all vernacular forms in-state. */ export function fetchWords() { return async (dispatch: StoreStateDispatch) => { const words = await getFrontierWords(); @@ -127,6 +131,9 @@ export function fetchWords() { }; } +/** Returns a dispatch function to: gather all characters (and number of occurrences) + * in the in-state `allWords` array and extract the inventory status of each character + * from the current project's `.validCharacters` and `.rejectedCharacters`. */ export function getAllCharacters() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { const allWords = getState().characterInventoryState.allWords; @@ -143,6 +150,8 @@ export function getAllCharacters() { }; } +/** Returns a dispatch function to: load all character inventory data for the current + * project (drawing from the in-state project and its frontier in the database.) */ export function loadCharInvData() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { const project = getState().currentProjectState.project; @@ -154,12 +163,41 @@ export function loadCharInvData() { }; } +/** Returns a dispatch function to: update every word in the current project's frontier + * that has the given `findValue` in its vernacular form. Then: + * - Update the in-state `allWords` array; + * - Update the in-state character inventory. */ +export function findAndReplace(findValue: string, replaceValue: string) { + return async (dispatch: StoreStateDispatch) => { + const changedWords = (await getFrontierWords()).filter((w) => + w.vernacular.includes(findValue) + ); + + // Use regular expressions to replace all findValue occurrences. + const findRegExp = new RegExp( + // Escape all special characters in findValue. + findValue.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"), + "g" + ); + for (const word of changedWords) { + word.vernacular = word.vernacular.replace(findRegExp, replaceValue); + await updateWord(word); + } + + await dispatch(fetchWords()); + await dispatch(getAllCharacters()); + }; +} + // Helper Functions +/** Navigate to the Data Cleanup page. */ export function exit(): void { router.navigate(Path.Goals); } +/** Count the number of occurrences of the given `char` in the given array of strings. + * Gives a console error if `char` is not length 1. */ function countOccurrences(char: string, words: string[]): number { if (char.length !== 1) { console.error(`countOccurrences expects length 1 char, but got: ${char}`); @@ -175,6 +213,8 @@ function countOccurrences(char: string, words: string[]): number { return count; } +/** Compare the `.validCharacters` and `.rejectedCharacters` between the given project + * and the given state to identify all characters with a change of inventory status. */ export function getChanges( project: Project, charInvState: CharacterInventoryState @@ -196,7 +236,8 @@ export function getChanges( return changes; } -// Returns undefined if CharacterStatus unchanged. +/** Return the given character's change of inventory status, or undefined if + * its CharacterStatus is unchanged. */ function getChange( c: string, oldAcc: string[], diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx index 30165ea245..9206b7c088 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx @@ -171,7 +171,7 @@ export default function MergeDragDrop(): ReactElement { {renderSidebar()} setSenseToDelete("")} handleConfirm={performDelete} />