diff --git a/.vscode/settings.json b/.vscode/settings.json index baa9e6cdb4..e80e35b6ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -86,6 +86,7 @@ "thecombine", "upsert", "venv", + "verns", "wordlist", "wordlists" ], diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index a2c7c5ccf6..cab3e9b365 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -225,6 +225,38 @@ private static async Task DownloadAndReadLift(LiftController liftControl return liftText; } + [Test] + public void TestUploadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.UploadLiftFile(_projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadLiftFileInvalidProjectId() + { + var result = _liftController.UploadLiftFile("../hack", new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadLiftFileAlreadyImported() + { + var projId = _projRepo.Create(new Project { Name = "already has import", LiftImported = true }).Result!.Id; + var result = _liftController.UploadLiftFile(projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((BadRequestObjectResult)result).Value, Contains.Substring("LIFT")); + } + + [Test] + public void TestUploadLiftFileBadFile() + { + var result = _liftController.UploadLiftFile(_projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((BadRequestObjectResult)result).Value, Is.InstanceOf()); + } + [Test] public void TestUploadLiftFileAndGetWritingSystems() { @@ -271,6 +303,21 @@ public void TestFinishUploadLiftFileNothingToFinish() Assert.That(_liftService.RetrieveImport(UserId), Is.Null); } + [Test] + public void TestFinishUploadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.FinishUploadLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestFinishUploadLiftFileInvalidProjectId() + { + var result = _liftController.FinishUploadLiftFile("../hack", UserId).Result; + Assert.That(result, Is.InstanceOf()); + } + [Test] public async Task TestModifiedTimeExportsToLift() { @@ -285,6 +332,35 @@ public async Task TestModifiedTimeExportsToLift() Assert.That(liftContents, Does.Contain("dateModified=\"2000-01-01T00:00:00Z\"")); } + [Test] + public void TestExportLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.ExportLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileInvalidProjectId() + { + var result = _liftController.ExportLiftFile("../hack").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileNoProject() + { + var result = _liftController.ExportLiftFile("non-existent-project").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileNoWordsInProject() + { + var result = _liftController.ExportLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + [Test] public void TestExportInvalidProjectId() { @@ -294,6 +370,47 @@ public void TestExportInvalidProjectId() Throws.TypeOf()); } + [Test] + public void TestDownloadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.DownloadLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.CanUploadLift(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftInvalidProjectId() + { + var result = _liftController.CanUploadLift("../hack").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftFalse() + { + var projId = _projRepo.Create(new Project { Name = "has import", LiftImported = true }).Result!.Id; + var result = _liftController.CanUploadLift(projId).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.False); + } + + [Test] + public void TestCanUploadLiftTrue() + { + var projId = _projRepo.Create(new Project { Name = "has no import", LiftImported = false }).Result!.Id; + var result = _liftController.CanUploadLift(projId).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.True); + } + /// /// Create three words and delete one. Ensure that the deleted word is still exported to Lift format and marked /// as deleted. diff --git a/Backend.Tests/Mocks/ProjectRepositoryMock.cs b/Backend.Tests/Mocks/ProjectRepositoryMock.cs index b38a8b0b6e..0b3e77628b 100644 --- a/Backend.Tests/Mocks/ProjectRepositoryMock.cs +++ b/Backend.Tests/Mocks/ProjectRepositoryMock.cs @@ -95,7 +95,8 @@ public Task Update(string projectId, Project project) public Task CanImportLift(string projectId) { - return Task.FromResult(true); + var project = _projects.Find(p => p.Id == projectId); + return Task.FromResult(project?.LiftImported != true); } } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 183b5bff91..c8a29d71a9 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; @@ -944,16 +945,17 @@ public void ProcessRangeElement(string range, string id, string guid, string par // The following are unused and are not implemented, but may still be called by the Lexicon Merger // They may be useful later if we need to add more complex attributes to words in The Combine + [ExcludeFromCodeCoverage] public LiftExample GetOrMakeExample(LiftSense sense, Extensible info) { return new LiftExample { Content = new LiftMultiText() }; } - + [ExcludeFromCodeCoverage] public LiftObject GetOrMakeParentReversal(LiftObject parent, LiftMultiText contents, string type) { return new LiftReversal(); } - + [ExcludeFromCodeCoverage] public LiftSense GetOrMakeSubsense(LiftSense sense, Extensible info, string rawXml) { return new LiftSense(info, new Guid(), sense) @@ -962,35 +964,40 @@ public LiftSense GetOrMakeSubsense(LiftSense sense, Extensible info, string rawX Gloss = new LiftMultiText() }; } - + [ExcludeFromCodeCoverage] public LiftObject MergeInEtymology(LiftEntry entry, string source, string type, LiftMultiText form, - LiftMultiText gloss, string rawXml) + LiftMultiText gloss, string rawXml) { return new LiftEtymology(); } - + [ExcludeFromCodeCoverage] public LiftObject MergeInReversal( - LiftSense sense, LiftObject parent, LiftMultiText contents, string type, string rawXml) + LiftSense sense, LiftObject parent, LiftMultiText contents, string type, string rawXml) { return new LiftReversal(); } - + [ExcludeFromCodeCoverage] public LiftObject MergeInVariant(LiftEntry entry, LiftMultiText contents, string rawXml) { return new LiftVariant(); } - + [ExcludeFromCodeCoverage] public void EntryWasDeleted(Extensible info, DateTime dateDeleted) { } + [ExcludeFromCodeCoverage] public void MergeInExampleForm(LiftExample example, LiftMultiText multiText) { } - + [ExcludeFromCodeCoverage] public void MergeInPicture(LiftSense sense, string href, LiftMultiText caption) { } + [ExcludeFromCodeCoverage] public void MergeInRelation( - LiftObject extensible, string relationTypeName, string targetId, string rawXml) + LiftObject extensible, string relationTypeName, string targetId, string rawXml) { } + [ExcludeFromCodeCoverage] public void MergeInSource(LiftExample example, string source) { } + [ExcludeFromCodeCoverage] public void MergeInTranslationForm( - LiftExample example, string type, LiftMultiText multiText, string rawXml) + LiftExample example, string type, LiftMultiText multiText, string rawXml) { } + [ExcludeFromCodeCoverage] public void ProcessFieldDefinition(string tag, LiftMultiText description) { } } } diff --git a/src/components/App/DefaultState.ts b/src/components/App/DefaultState.ts index 5e981553ad..26df9445ac 100644 --- a/src/components/App/DefaultState.ts +++ b/src/components/App/DefaultState.ts @@ -4,20 +4,20 @@ import { defaultState as currentProjectState } from "components/Project/ProjectR import { defaultState as exportProjectState } from "components/ProjectExport/Redux/ExportProjectReduxTypes"; import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { defaultState as treeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { defaultState as mergeDuplicateGoal } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { defaultState as reviewEntriesState } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesReduxTypes"; import { defaultState as analyticsState } from "types/Redux/analyticsReduxTypes"; export const defaultState = { - //login + //login and signup loginState: { ...loginState }, //project currentProjectState: { ...currentProjectState }, exportProjectState: { ...exportProjectState }, - //data entry and review entries + //data entry and review entries goal treeViewState: { ...treeViewState }, reviewEntriesState: { ...reviewEntriesState }, pronunciationsState: { ...pronunciationsState }, @@ -25,7 +25,7 @@ export const defaultState = { //goal timeline and current goal goalsState: { ...goalTimelineState }, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: { ...mergeDuplicateGoal }, //character inventory goal diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx index 11132c97f4..14df7766a1 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx @@ -1,8 +1,9 @@ +import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, - Divider, Grid, + IconButton, MenuList, Typography, } from "@mui/material"; @@ -50,7 +51,7 @@ export default function SenseDialog(props: SenseDialogProps): ReactElement { interface SenseListProps { selectedWord: Word; - closeDialog: (gloss: string) => void; + closeDialog: (gloss?: string) => void; analysisLang: string; } @@ -95,11 +96,7 @@ export function SenseList(props: SenseListProps): ReactElement { ); }; - const menuItems: ReactElement[] = []; - for (const s of props.selectedWord.senses) { - menuItems.push(menuItem(s)); - menuItems.push(); - } + const menuItems = props.selectedWord.senses.map(menuItem); menuItems.push( props.closeDialog("")}> {t("addWords.newSenseFor")} @@ -109,7 +106,16 @@ export function SenseList(props: SenseListProps): ReactElement { return ( <> + {/* Cancel button */} + props.closeDialog()} + style={{ position: "absolute", right: 0, top: 0 }} + > + + + {/* Header */} {t("addWords.selectSense")} + {/* Sense options */} {menuItems} ); diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts index 0410fedf5b..2cb3895f4f 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts +++ b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts @@ -4,6 +4,9 @@ import { withStyles } from "@mui/styles"; // Copied from customized menus at https://material-ui.com/components/menus/ const StyledMenuItem = withStyles((theme) => ({ root: { + border: "1px solid gray", + borderRadius: "8px", + marginTop: "8px", "&:focus": { backgroundColor: theme.palette.primary.main, "& .MuiListItemIcon-root, & .MuiListItemText-primary": { diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx index 77e96dc084..b489398dff 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx @@ -1,8 +1,9 @@ +import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, - Divider, Grid, + IconButton, MenuList, Typography, } from "@mui/material"; @@ -50,7 +51,7 @@ export default function VernDialog(props: vernDialogProps): ReactElement { interface VernListProps { vernacularWords: Word[]; - closeDialog: (wordId: string) => void; + closeDialog: (wordId?: string) => void; analysisLang?: string; } @@ -96,11 +97,7 @@ export function VernList(props: VernListProps): ReactElement { ); }; - const menuItems: ReactElement[] = []; - for (const w of props.vernacularWords) { - menuItems.push(menuItem(w)); - menuItems.push(); - } + const menuItems = props.vernacularWords.map(menuItem); menuItems.push( props.closeDialog("")}> {t("addWords.newEntryFor")} @@ -110,7 +107,16 @@ export function VernList(props: VernListProps): ReactElement { return ( <> + {/* Cancel button */} + props.closeDialog()} + style={{ position: "absolute", right: 0, top: 0 }} + > + + + {/* Header */} {t("addWords.selectEntry")} + {/* Entry options */} {menuItems} ); diff --git a/src/components/DataEntry/index.tsx b/src/components/DataEntry/index.tsx index 29aeb962ef..8a03c5a48b 100644 --- a/src/components/DataEntry/index.tsx +++ b/src/components/DataEntry/index.tsx @@ -15,10 +15,7 @@ import DataEntryTable from "components/DataEntry/DataEntryTable"; import ExistingDataTable from "components/DataEntry/ExistingDataTable"; import { filterWordsByDomain } from "components/DataEntry/utilities"; import TreeView from "components/TreeView"; -import { - closeTreeAction, - openTreeAction, -} from "components/TreeView/Redux/TreeViewActions"; +import { closeTree, openTree } from "components/TreeView/Redux/TreeViewActions"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import { newSemanticDomain } from "types/semanticDomain"; @@ -73,7 +70,7 @@ export default function DataEntry(): ReactElement { // On first render, open tree. useLayoutEffect(() => { - dispatch(openTreeAction()); + dispatch(openTree()); }, [dispatch]); // When window width changes, check if there's space for the sidebar. @@ -99,7 +96,7 @@ export default function DataEntry(): ReactElement { setDomainWords( filterWordsByDomain(await getFrontierWords(), id, analysisLang) ); - dispatch(closeTreeAction()); + dispatch(closeTree()); }, [analysisLang, dispatch, id]); return ( @@ -118,7 +115,7 @@ export default function DataEntry(): ReactElement { hasDrawerButton={isSmallScreen && domainWords.length > 0} hideQuestions={() => setQuestionsVisible(false)} isTreeOpen={open} - openTree={() => dispatch(openTreeAction())} + openTree={() => dispatch(openTree())} semanticDomain={currentDomain} showExistingData={() => setDrawerOpen(true)} updateHeight={updateHeight} diff --git a/src/components/DataEntry/tests/index.test.tsx b/src/components/DataEntry/tests/index.test.tsx index 85a4ec1aff..a459dc7ef9 100644 --- a/src/components/DataEntry/tests/index.test.tsx +++ b/src/components/DataEntry/tests/index.test.tsx @@ -9,11 +9,8 @@ import DataEntry, { treeViewDialogId, } from "components/DataEntry"; import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; -import { openTreeAction } from "components/TreeView/Redux/TreeViewActions"; -import { - TreeViewAction, - TreeViewState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; +import { openTree } from "components/TreeView/Redux/TreeViewActions"; +import { TreeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; import { newSemanticDomainTreeNode } from "types/semanticDomain"; import * as useWindowSize from "utilities/useWindowSize"; @@ -39,7 +36,7 @@ jest.mock("types/hooks", () => { }; }); -const mockDispatch = jest.fn((action: TreeViewAction) => action); +const mockDispatch = jest.fn((action: any) => action); const mockDomain = newSemanticDomainTreeNode("mockId", "mockName", "mockLang"); const mockGetSemanticDomainFull = jest.fn(); const mockStore = createMockStore(); @@ -72,7 +69,7 @@ describe("DataEntry", () => { it("dispatches to open the tree", async () => { await renderDataEntry({ currentDomain: mockDomain }); - expect(mockDispatch).toHaveBeenCalledWith(openTreeAction()); + expect(mockDispatch).toHaveBeenCalledWith(openTree()); }); it("fetches domain", async () => { diff --git a/src/components/ProjectScreen/index.tsx b/src/components/ProjectScreen/index.tsx index 67af430b19..363b8d88d2 100644 --- a/src/components/ProjectScreen/index.tsx +++ b/src/components/ProjectScreen/index.tsx @@ -4,7 +4,7 @@ import { ReactElement, useEffect } from "react"; import { clearCurrentProject } from "components/Project/ProjectActions"; import ChooseProject from "components/ProjectScreen/ChooseProject"; import CreateProject from "components/ProjectScreen/CreateProject"; -import { resetTreeAction } from "components/TreeView/Redux/TreeViewActions"; +import { resetTree } from "components/TreeView/Redux/TreeViewActions"; import { useAppDispatch } from "types/hooks"; /** Where users create a project or choose an existing one */ @@ -13,7 +13,7 @@ export default function ProjectScreen(): ReactElement { /* Disable Data Entry, Data Cleanup, Project Settings until a project is selected or created. */ useEffect(() => { dispatch(clearCurrentProject()); - dispatch(resetTreeAction()); + dispatch(resetTree()); }, [dispatch]); return ( diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index fae2ac34d5..421b05bd5a 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next"; import { ButtonConfirmation } from "components/Dialogs"; import { playing, - reset, + resetPronunciations, } from "components/Pronunciations/Redux/PronunciationsActions"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; @@ -38,8 +38,8 @@ const useStyles = makeStyles((theme: Theme) => export default function AudioPlayer(props: PlayerProps): ReactElement { const isPlaying = useAppSelector( (state: StoreState) => - state.pronunciationsState.payload === props.fileName && - state.pronunciationsState.type === PronunciationsStatus.Playing + state.pronunciationsState.fileName === props.fileName && + state.pronunciationsState.status === PronunciationsStatus.Playing ); const [audio] = useState(new Audio(props.pronunciationUrl)); @@ -48,7 +48,10 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { const classes = useStyles(); const dispatch = useAppDispatch(); - const dispatchReset = useCallback(() => dispatch(reset()), [dispatch]); + const dispatchReset = useCallback( + () => dispatch(resetPronunciations()), + [dispatch] + ); const { t } = useTranslation(); useEffect(() => { diff --git a/src/components/Pronunciations/RecorderIcon.tsx b/src/components/Pronunciations/RecorderIcon.tsx index b19e04a872..c7aeafb629 100644 --- a/src/components/Pronunciations/RecorderIcon.tsx +++ b/src/components/Pronunciations/RecorderIcon.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { recording, - reset, + resetPronunciations, } from "components/Pronunciations/Redux/PronunciationsActions"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; @@ -23,9 +23,12 @@ interface RecorderIconProps { } export default function RecorderIcon(props: RecorderIconProps): ReactElement { - const pronunciationsState = useAppSelector( - (state: StoreState) => state.pronunciationsState + const isRecording = useAppSelector( + (state: StoreState) => + state.pronunciationsState.status === PronunciationsStatus.Recording && + state.pronunciationsState.wordId === props.wordId ); + const dispatch = useAppDispatch(); const { t } = useTranslation(); @@ -43,7 +46,7 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { } function toggleIsRecordingToFalse(): void { props.stopRecording(); - dispatch(reset()); + dispatch(resetPronunciations()); } function handleTouchStart(): void { @@ -77,12 +80,7 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { tabIndex={-1} > diff --git a/src/components/Pronunciations/Redux/PronunciationsActions.ts b/src/components/Pronunciations/Redux/PronunciationsActions.ts index 89a44d5ebb..4e63d7a14a 100644 --- a/src/components/Pronunciations/Redux/PronunciationsActions.ts +++ b/src/components/Pronunciations/Redux/PronunciationsActions.ts @@ -1,22 +1,21 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { - PronunciationsAction, - PronunciationsStatus, -} from "components/Pronunciations/Redux/PronunciationsReduxTypes"; + resetAction, + setPlayingAction, + setRecordingAction, +} from "components/Pronunciations/Redux/PronunciationsReducer"; + +// Action Creation Functions -export function playing(payload: string): PronunciationsAction { - return { - type: PronunciationsStatus.Playing, - payload, - }; +export function playing(fileName: string): PayloadAction { + return setPlayingAction(fileName); } -export function recording(payload: string): PronunciationsAction { - return { - type: PronunciationsStatus.Recording, - payload, - }; +export function recording(wordId: string): PayloadAction { + return setRecordingAction(wordId); } -export function reset(): PronunciationsAction { - return { type: PronunciationsStatus.Default }; +export function resetPronunciations(): Action { + return resetAction(); } diff --git a/src/components/Pronunciations/Redux/PronunciationsReducer.ts b/src/components/Pronunciations/Redux/PronunciationsReducer.ts index e8c7e40b98..a37e046ae2 100644 --- a/src/components/Pronunciations/Redux/PronunciationsReducer.ts +++ b/src/components/Pronunciations/Redux/PronunciationsReducer.ts @@ -1,25 +1,32 @@ +import { createSlice } from "@reduxjs/toolkit"; + import { defaultState, - PronunciationsAction, PronunciationsStatus, - PronunciationsState, } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { StoreActionTypes } from "rootActions"; + +const pronunciationsSlice = createSlice({ + name: "pronunciationsState", + initialState: defaultState, + reducers: { + resetAction: () => defaultState, + setPlayingAction: (state, action) => { + state.fileName = action.payload; + state.status = PronunciationsStatus.Playing; + state.wordId = ""; + }, + setRecordingAction: (state, action) => { + state.fileName = ""; + state.status = PronunciationsStatus.Recording; + state.wordId = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { resetAction, setPlayingAction, setRecordingAction } = + pronunciationsSlice.actions; -export const pronunciationsReducer = ( - state: PronunciationsState = defaultState, - action: StoreAction | PronunciationsAction -): PronunciationsState => { - switch (action.type) { - case PronunciationsStatus.Playing: - return { ...defaultState, ...action }; - case PronunciationsStatus.Recording: - return { ...defaultState, ...action }; - case PronunciationsStatus.Default: - return defaultState; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; +export default pronunciationsSlice.reducer; diff --git a/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts b/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts index e4804d86ca..d0d1357012 100644 --- a/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts +++ b/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts @@ -1,20 +1,17 @@ export enum PronunciationsStatus { - Default = "DEFAULT", + Inactive = "INACTIVE", Playing = "PLAYING", Recording = "RECORDING", } -export interface PronunciationsAction { - type: PronunciationsStatus; - payload?: string; -} - export interface PronunciationsState { - type: PronunciationsStatus; - payload: string; + fileName: string; + status: PronunciationsStatus; + wordId: string; } export const defaultState: PronunciationsState = { - type: PronunciationsStatus.Default, - payload: "", + fileName: "", + status: PronunciationsStatus.Inactive, + wordId: "", }; diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index 1c6bedbbfd..4d59607eb1 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -1,6 +1,6 @@ import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; @@ -11,32 +11,31 @@ import RecorderIcon, { recordIconId, } from "components/Pronunciations/RecorderIcon"; import { - PronunciationsState, defaultState as pronunciationsState, PronunciationsStatus, } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import { StoreState } from "types"; import theme from "types/theme"; jest.mock("components/Pronunciations/Recorder"); -let testRenderer: renderer.ReactTestRenderer; +let testRenderer: ReactTestRenderer; const createMockStore = configureMockStore(); const mockStore = createMockStore({ pronunciationsState }); -function mockRecordingState(wordId: string): { - pronunciationsState: Partial; -} { +function mockRecordingState(wordId: string): Partial { return { pronunciationsState: { - type: PronunciationsStatus.Recording, - payload: wordId, + fileName: "", + status: PronunciationsStatus.Recording, + wordId, }, }; } beforeAll(() => { - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -52,8 +51,8 @@ describe("Pronunciations", () => { test("pointerDown and pointerUp", () => { const mockStartRecording = jest.fn(); const mockStopRecording = jest.fn(); - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -78,8 +77,8 @@ describe("Pronunciations", () => { }); test("default style is iconRelease", () => { - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -96,8 +95,8 @@ describe("Pronunciations", () => { test("style depends on pronunciations state", () => { const wordId = "1"; const mockStore2 = createMockStore(mockRecordingState(wordId)); - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( diff --git a/src/components/TreeView/Redux/TreeViewActions.ts b/src/components/TreeView/Redux/TreeViewActions.ts index 808adf7b83..f193907f47 100644 --- a/src/components/TreeView/Redux/TreeViewActions.ts +++ b/src/components/TreeView/Redux/TreeViewActions.ts @@ -1,60 +1,57 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { SemanticDomain, SemanticDomainTreeNode } from "api/models"; import { getSemanticDomainTreeNode } from "backend"; import { - TreeActionType, - TreeViewAction, -} from "components/TreeView/Redux/TreeViewReduxTypes"; + resetTreeAction, + setCurrentDomainAction, + setDomainLanguageAction, + setTreeOpenAction, +} from "components/TreeView/Redux/TreeViewReducer"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; -export function closeTreeAction(): TreeViewAction { - return { type: TreeActionType.CLOSE_TREE }; +// Action Creation Functions + +export function closeTree(): PayloadAction { + return setTreeOpenAction(false); } -export function openTreeAction(): TreeViewAction { - return { type: TreeActionType.OPEN_TREE }; +export function openTree(): PayloadAction { + return setTreeOpenAction(true); } -export function setDomainAction( - domain: SemanticDomainTreeNode -): TreeViewAction { - return { type: TreeActionType.SET_CURRENT_DOMAIN, domain }; +export function resetTree(): Action { + return resetTreeAction(); } -export function setDomainLanguageAction(language: string): TreeViewAction { - return { type: TreeActionType.SET_DOMAIN_LANGUAGE, language }; +export function setCurrentDomain( + domain: SemanticDomainTreeNode +): PayloadAction { + return setCurrentDomainAction(domain); } -export function resetTreeAction(): TreeViewAction { - return { type: TreeActionType.RESET_TREE }; +export function setDomainLanguage(language: string): PayloadAction { + return setDomainLanguageAction(language); } +// Dispatch Functions + export function traverseTree(domain: SemanticDomain) { return async (dispatch: StoreStateDispatch) => { - if (domain) { - await getSemanticDomainTreeNode(domain.id, domain.lang).then( - (response) => { - if (response) { - dispatch(setDomainAction(response)); - } - } - ); - } - }; -} - -export function updateTreeLanguage(language: string) { - return (dispatch: StoreStateDispatch) => { - if (language) { - dispatch(setDomainLanguageAction(language)); + if (domain.id) { + const dom = await getSemanticDomainTreeNode(domain.id, domain.lang); + if (dom) { + dispatch(setCurrentDomain(dom)); + } } }; } -export function initTreeDomain(language = "") { +export function initTreeDomain(lang = "") { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const currentDomain = getState().treeViewState.currentDomain; - currentDomain.lang = language; - await dispatch(traverseTree(currentDomain)); + await dispatch( + traverseTree({ ...getState().treeViewState.currentDomain, lang }) + ); }; } diff --git a/src/components/TreeView/Redux/TreeViewReducer.ts b/src/components/TreeView/Redux/TreeViewReducer.ts index 148fa5c314..8de4cdedb7 100644 --- a/src/components/TreeView/Redux/TreeViewReducer.ts +++ b/src/components/TreeView/Redux/TreeViewReducer.ts @@ -1,39 +1,33 @@ -import { - TreeViewAction, - TreeActionType, - TreeViewState, - defaultState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { createSlice } from "@reduxjs/toolkit"; -export const treeViewReducer = ( - state: TreeViewState = defaultState, - action: StoreAction | TreeViewAction -): TreeViewState => { - switch (action.type) { - case TreeActionType.CLOSE_TREE: - return { ...state, open: false }; - case TreeActionType.OPEN_TREE: - return { ...state, open: true }; - case TreeActionType.RESET_TREE: - return defaultState; - case TreeActionType.SET_DOMAIN_LANGUAGE: - if (!action.language) { - throw new Error("Cannot set domain language to undefined."); - } - return { - ...state, - currentDomain: { ...state.currentDomain, lang: action.language }, - language: action.language, - }; - case TreeActionType.SET_CURRENT_DOMAIN: - if (!action.domain) { - throw new Error("Cannot set the current domain to undefined."); - } - return { ...state, currentDomain: action.domain }; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; +import { defaultState } from "components/TreeView/Redux/TreeViewReduxTypes"; +import { StoreActionTypes } from "rootActions"; + +const treeViewSlice = createSlice({ + name: "treeViewState", + initialState: defaultState, + reducers: { + resetTreeAction: () => defaultState, + setCurrentDomainAction: (state, action) => { + state.currentDomain = action.payload; + }, + setDomainLanguageAction: (state, action) => { + state.currentDomain.lang = action.payload; + state.language = action.payload; + }, + setTreeOpenAction: (state, action) => { + state.open = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { + resetTreeAction, + setCurrentDomainAction, + setDomainLanguageAction, + setTreeOpenAction, +} = treeViewSlice.actions; + +export default treeViewSlice.reducer; diff --git a/src/components/TreeView/Redux/TreeViewReduxTypes.tsx b/src/components/TreeView/Redux/TreeViewReduxTypes.tsx index b210d50f51..922a2e62b8 100644 --- a/src/components/TreeView/Redux/TreeViewReduxTypes.tsx +++ b/src/components/TreeView/Redux/TreeViewReduxTypes.tsx @@ -1,20 +1,6 @@ import { SemanticDomainTreeNode } from "api/models"; import { newSemanticDomainTreeNode } from "types/semanticDomain"; -export enum TreeActionType { - CLOSE_TREE = "CLOSE_TREE", - OPEN_TREE = "OPEN_TREE", - RESET_TREE = "RESET_TREE", - SET_DOMAIN_LANGUAGE = "SET_DOMAIN_LANGUAGE", - SET_CURRENT_DOMAIN = "SET_CURRENT_DOMAIN", -} - -export interface TreeViewAction { - type: TreeActionType; - domain?: SemanticDomainTreeNode; - language?: string; -} - export interface TreeViewState { currentDomain: SemanticDomainTreeNode; language: string; diff --git a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx index d00025db16..19eebad99b 100644 --- a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx +++ b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx @@ -1,19 +1,19 @@ -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { PreloadedState } from "redux"; +import { defaultState } from "components/App/DefaultState"; import { - setDomainLanguageAction, + initTreeDomain, + setDomainLanguage, traverseTree, } from "components/TreeView/Redux/TreeViewActions"; +import { RootState, setupStore } from "store"; import { - defaultState, - TreeActionType, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { newSemanticDomainTreeNode } from "types/semanticDomain"; + newSemanticDomain, + newSemanticDomainTreeNode, +} from "types/semanticDomain"; jest.mock("backend", () => ({ - getSemanticDomainTreeNode: (id: string, lang: string) => - mockGetSemDomTreeNode(id, lang), + getSemanticDomainTreeNode: (...args: any[]) => mockGetSemDomTreeNode(...args), })); const mockGetSemDomTreeNode = jest.fn(); @@ -21,41 +21,58 @@ const mockGetSemDomTreeNode = jest.fn(); // Mock the track and identify methods of segment analytics. global.analytics = { identify: jest.fn(), track: jest.fn() } as any; -const createMockStore = configureMockStore([thunk]); -const mockState = defaultState; - -describe("TraverseTreeAction", () => { - it("SetDomainLanguage returns correct action", async () => { - const language = "lang"; - const action = { - type: TreeActionType.SET_DOMAIN_LANGUAGE, - language, - }; - const mockStore = createMockStore(mockState); - await mockStore.dispatch(setDomainLanguageAction("lang")); - expect(mockStore.getActions()).toEqual([action]); - }); +const mockId = "id"; +const mockLang = "lang"; + +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; - it("TraverseTreeAction dispatches on successful", async () => { - const mockDomainReturned = newSemanticDomainTreeNode("id", "name"); - mockGetSemDomTreeNode.mockResolvedValue(mockDomainReturned); - const domain = { id: "id", name: "name", guid: "", lang: "" }; - const action = { - type: TreeActionType.SET_CURRENT_DOMAIN, - domain: mockDomainReturned, - }; - const mockStore = createMockStore(mockState); - - await mockStore.dispatch(traverseTree(domain)); - expect(mockStore.getActions()).toEqual([action]); +describe("TreeViewActions", () => { + describe("setDomainLanguage", () => { + it("correctly affects state", async () => { + const store = setupStore(); + store.dispatch(setDomainLanguage(mockLang)); + const { currentDomain, language } = store.getState().treeViewState; + expect(currentDomain.lang).toEqual(mockLang); + expect(language).toEqual(mockLang); + }); }); - it("TraverseTreeAction does not dispatch on null return", async () => { - mockGetSemDomTreeNode.mockResolvedValue(undefined); - const domain = { id: "id", name: "name", guid: "", lang: "" }; - const mockStore = createMockStore(mockState); + describe("traverseTree", () => { + it("dispatches on successful", async () => { + const store = setupStore(); + const dom = newSemanticDomain(mockId); + mockGetSemDomTreeNode.mockResolvedValue(dom); + await store.dispatch(traverseTree(dom)); + const { currentDomain } = store.getState().treeViewState; + expect(currentDomain.id).toEqual(mockId); + }); + + it("does not dispatch on undefined", async () => { + const store = setupStore(); + mockGetSemDomTreeNode.mockResolvedValue(undefined); + await store.dispatch(traverseTree(newSemanticDomain(mockId))); + const { currentDomain } = store.getState().treeViewState; + expect(currentDomain.id).not.toEqual(mockId); + }); + }); - await mockStore.dispatch(traverseTree(domain)); - expect(mockStore.getActions()).toEqual([]); + describe("initTreeDomain", () => { + it("changes domain lang but not id", async () => { + const nonDefaultState = { + currentDomain: newSemanticDomainTreeNode(mockId), + language: "", + open: true, + }; + const store = setupStore({ + ...persistedDefaultState, + treeViewState: nonDefaultState, + }); + await store.dispatch(initTreeDomain(mockLang)); + expect(mockGetSemDomTreeNode).toBeCalledWith(mockId, mockLang); + }); }); }); diff --git a/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx b/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx deleted file mode 100644 index 58b03da1f7..0000000000 --- a/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { treeViewReducer } from "components/TreeView/Redux/TreeViewReducer"; -import { - defaultState, - TreeActionType, - TreeViewAction, - TreeViewState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newSemanticDomainTreeNode } from "types/semanticDomain"; - -describe("Test the TreeViewReducer", () => { - it("Returns defaultState when passed undefined", () => { - expect(treeViewReducer(undefined, {} as TreeViewAction)).toEqual( - defaultState - ); - }); - - it("Returns default state when tree reset action is passed", () => { - const action: TreeViewAction = { type: TreeActionType.RESET_TREE }; - expect(treeViewReducer({} as TreeViewState, action)).toEqual(defaultState); - }); - - it("Returns default state when store reset action is passed", () => { - const action: StoreAction = { type: StoreActionTypes.RESET }; - expect(treeViewReducer({} as TreeViewState, action)).toEqual(defaultState); - }); - - it("Returns state passed in when passed an invalid action", () => { - const badAction = { type: "Nothing" } as any as TreeViewAction; - expect(treeViewReducer({ ...defaultState, open: true }, badAction)).toEqual( - { ...defaultState, open: true } - ); - }); - - it("Closes the tree when requested", () => { - expect( - treeViewReducer( - { ...defaultState, open: true }, - { type: TreeActionType.CLOSE_TREE } - ) - ).toEqual({ ...defaultState, open: false }); - }); - - it("Opens the tree when requested", () => { - expect( - treeViewReducer( - { ...defaultState, open: false }, - { type: TreeActionType.OPEN_TREE } - ) - ).toEqual({ ...defaultState, open: true }); - }); - - it("Returns state with a new SemanticDomain when requested to change this value", () => { - const payload = newSemanticDomainTreeNode("testId", "testName"); - expect( - treeViewReducer(defaultState, { - type: TreeActionType.SET_CURRENT_DOMAIN, - domain: payload, - }) - ).toEqual({ ...defaultState, currentDomain: payload }); - }); -}); diff --git a/src/components/TreeView/TreeSearch.tsx b/src/components/TreeView/TreeSearch.tsx index 9372cc1c5b..34d8d6dd46 100644 --- a/src/components/TreeView/TreeSearch.tsx +++ b/src/components/TreeView/TreeSearch.tsx @@ -18,9 +18,22 @@ export const testId = "testSearch"; export default function TreeSearch(props: TreeSearchProps): ReactElement { const { t } = useTranslation(); - const { input, handleChange, searchAndSelectDomain, searchError } = + const { input, handleChange, searchAndSelectDomain, searchError, setInput } = useTreeSearch(props); + const handleOnKeyUp = (event: React.KeyboardEvent): void => { + event.bubbles = false; + if (event.key === Key.Enter) { + // Use onKeyUp so that this fires after onChange, to facilitate + // error state clearing. + event.stopPropagation(); + searchAndSelectDomain(event); + } else if (event.key === Key.Backspace) + if (input && input[input.length - 1] === ".") { + setInput(input.slice(0, input.length - 1)); + } + }; + return ( ) => void; searchAndSelectDomain: (event: React.KeyboardEvent) => void; searchError: boolean; + setInput: (text: string) => void; } // Exported for unit testing only @@ -96,26 +108,20 @@ export function useTreeSearch(props: TreeSearchProps): TreeSearchState { async function searchAndSelectDomain( event: React.KeyboardEvent ): Promise { - event.bubbles = false; - - if (event.key === Key.Enter) { - event.preventDefault(); - - // Search for domain - let domain: SemanticDomainTreeNode | undefined; - if (!isNaN(parseInt(input))) { - domain = await getSemanticDomainTreeNode(input, lang); - } else { - domain = await searchDomainByName(input); - } - if (domain) { - animateSuccessfulSearch(domain, event); - // Return to indicate success and skip setting error state. - return; - } - // Did not find a domain through either numerical or textual search. - setSearchError(true); + // Search for domain + let domain: SemanticDomainTreeNode | undefined; + if (!isNaN(parseInt(input))) { + domain = await getSemanticDomainTreeNode(input, lang); + } else { + domain = await searchDomainByName(input); + } + if (domain) { + animateSuccessfulSearch(domain, event); + // Return to indicate success and skip setting error state. + return; } + // Did not find a domain through either numerical or textual search. + setSearchError(true); } // Change the input on typing @@ -131,6 +137,7 @@ export function useTreeSearch(props: TreeSearchProps): TreeSearchState { handleChange, searchAndSelectDomain, searchError, + setInput, }; } diff --git a/src/components/TreeView/index.tsx b/src/components/TreeView/index.tsx index 09602f8d7f..1527363fcf 100644 --- a/src/components/TreeView/index.tsx +++ b/src/components/TreeView/index.tsx @@ -9,8 +9,8 @@ import { SemanticDomain, WritingSystem } from "api"; import { IconButtonWithTooltip } from "components/Buttons"; import { initTreeDomain, + setDomainLanguage, traverseTree, - updateTreeLanguage, } from "components/TreeView/Redux/TreeViewActions"; import { defaultTreeNode } from "components/TreeView/Redux/TreeViewReduxTypes"; import TreeDepiction from "components/TreeView/TreeDepiction"; @@ -57,7 +57,7 @@ export default function TreeView(props: TreeViewProps): ReactElement { const newLang = getSemDomWritingSystem(semDomWritingSystem)?.bcp47 ?? resolvedLanguage; if (newLang && newLang !== semDomLanguage) { - dispatch(updateTreeLanguage(newLang)); + dispatch(setDomainLanguage(newLang)); } dispatch(initTreeDomain(newLang)); }, [semDomLanguage, semDomWritingSystem, dispatch, resolvedLanguage]); diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index a49b42547e..12b4f41014 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -16,7 +16,7 @@ import { buttonIdSubmit, } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace"; import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; // Dialog uses portals, which are not supported in react-test-renderer. diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx index 6cae3c7e17..b25f18bb31 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx @@ -36,8 +36,9 @@ export default function CharacterList(): ReactElement { }; useEffect(() => { - setOrderedChars(sortBy(allChars, sortOrder)); - }, [allChars, setOrderedChars, sortOrder]); + // Spread allChars to not mutate the Redux state. + setOrderedChars(sortBy([...allChars], sortOrder)); + }, [allChars, sortOrder]); return ( <> diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx index 8f56dad1f5..e501972fbd 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx @@ -1,24 +1,26 @@ import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import CharacterList from "goals/CharacterInventory/CharInv/CharacterList"; import CharacterCard from "goals/CharacterInventory/CharInv/CharacterList/CharacterCard"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { newCharacterSetEntry } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; +import { + defaultState, + newCharacterSetEntry, +} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; const characterSet = ["q", "w", "e", "r", "t", "y"].map(newCharacterSetEntry); const mockStore = configureMockStore()({ characterInventoryState: { ...defaultState, characterSet }, }); -let testRenderer: renderer.ReactTestRenderer; +let testRenderer: ReactTestRenderer; beforeEach(async () => { - await renderer.act(async () => { - testRenderer = renderer.create( + await act(async () => { + testRenderer = create( diff --git a/src/goals/CharacterInventory/CharInv/index.tsx b/src/goals/CharacterInventory/CharInv/index.tsx index 83376299a7..34a853cc27 100644 --- a/src/goals/CharacterInventory/CharInv/index.tsx +++ b/src/goals/CharacterInventory/CharInv/index.tsx @@ -18,7 +18,7 @@ import CharacterSetHeader from "goals/CharacterInventory/CharInv/CharacterSetHea import { exit, loadCharInvData, - resetInState, + resetCharInv, setSelectedCharacter, uploadInventory, } from "goals/CharacterInventory/Redux/CharacterInventoryActions"; @@ -54,7 +54,7 @@ export default function CharacterInventory(): ReactElement { dispatch(loadCharInvData()); // Call when component unmounts. - () => dispatch(resetInState()); + () => dispatch(resetCharInv()); }, [dispatch]); const save = async (): Promise => { diff --git a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx index 88a6106331..c8ed1d0c98 100644 --- a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx @@ -11,7 +11,7 @@ import CharInv, { dialogButtonIdYes, dialogIdCancel, } from "goals/CharacterInventory/CharInv"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; // Replace Dialog with something that doesn't create portals, // because react-test-renderer does not support portals. @@ -27,7 +27,7 @@ jest.mock("goals/CharacterInventory/CharInv/CharacterDetail", () => "div"); jest.mock("goals/CharacterInventory/Redux/CharacterInventoryActions", () => ({ exit: () => mockExit(), loadCharInvData: () => mockLoadCharInvData(), - resetInState: () => jest.fn(), + reset: () => jest.fn(), setSelectedCharacter: () => mockSetSelectedCharacter(), uploadInventory: () => mockUploadInventory(), })); diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index fc75d252d6..c14ac5bc33 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -1,3 +1,5 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { Project } from "api/models"; import { getFrontierWords } from "backend"; import router from "browserRouter"; @@ -10,84 +12,59 @@ import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; +import { + addRejectedCharacterAction, + addValidCharacterAction, + resetCharInvAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { CharacterInventoryState, CharacterSetEntry, - CharacterInventoryAction, - CharacterInventoryType, getCharacterStatus, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; -// Action Creators +// Action Creation Functions -export function addToValidCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_VALID_CHARACTERS, - payload: chars, - }; +export function addRejectedCharacter(char: string): PayloadAction { + return addRejectedCharacterAction(char); } -export function addToRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS, - payload: chars, - }; +export function addValidCharacter(char: string): PayloadAction { + return addValidCharacterAction(char); } -export function setValidCharacters(chars: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: chars, - }; +export function resetCharInv(): Action { + return resetCharInvAction(); } -export function setRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_REJECTED_CHARACTERS, - payload: chars, - }; +export function setAllWords(words: string[]): PayloadAction { + return setAllWordsAction(words); } -export function setAllWords(words: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_ALL_WORDS, - payload: words, - }; +export function setCharacterSet( + characterSet: CharacterSetEntry[] +): PayloadAction { + return setCharacterSetAction(characterSet); } -export function setSelectedCharacter( - character: string -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_SELECTED_CHARACTER, - payload: [character], - }; +export function setRejectedCharacters(chars: string[]): PayloadAction { + return setRejectedCharactersAction(chars); } -export function setCharacterSet( - characterSet: CharacterSetEntry[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_CHARACTER_SET, - payload: [], - characterSet, - }; +export function setSelectedCharacter(character: string): PayloadAction { + return setSelectedCharacterAction(character); } -export function resetInState(): CharacterInventoryAction { - return { - type: CharacterInventoryType.RESET, - payload: [], - }; +export function setValidCharacters(chars: string[]): PayloadAction { + return setValidCharactersAction(chars); } // Dispatch Functions @@ -96,10 +73,10 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { return (dispatch: StoreStateDispatch, getState: () => StoreState) => { switch (status) { case CharacterStatus.Accepted: - dispatch(addToValidCharacters([character])); + dispatch(addValidCharacter(character)); break; case CharacterStatus.Rejected: - dispatch(addToRejectedCharacters([character])); + dispatch(addRejectedCharacter(character)); break; case CharacterStatus.Undecided: const state = getState().characterInventoryState; @@ -120,17 +97,23 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { }; } -// Sends the character inventory to the server. +/** Sends the in-state character inventory to the server. */ export function uploadInventory() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const changes = getChangesFromState(state); + const charInvState = getState().characterInventoryState; + const project = getState().currentProjectState.project; + const changes = getChanges(project, charInvState); if (!changes.length) { exit(); return; } - const updatedProject = updateCurrentProject(state); - await dispatch(asyncUpdateCurrentProject(updatedProject)); + await dispatch( + asyncUpdateCurrentProject({ + ...project, + rejectedCharacters: charInvState.rejectedCharacters, + validCharacters: charInvState.validCharacters, + }) + ); dispatch(addCharInvChangesToGoal(changes)); await dispatch(asyncUpdateGoal()); exit(); @@ -146,28 +129,17 @@ export function fetchWords() { export function getAllCharacters() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const words = await getFrontierWords(); - const charactersWithDuplicates: string[] = []; - words.forEach((word) => charactersWithDuplicates.push(...word.vernacular)); - const characters = [...new Set(charactersWithDuplicates)]; - - const characterSet: CharacterSetEntry[] = []; - characters.forEach((letter) => { - characterSet.push({ - character: letter, - occurrences: countCharacterOccurrences( - letter, - words.map((word) => word.vernacular) - ), - status: getCharacterStatus( - letter, - state.currentProjectState.project.validCharacters, - state.currentProjectState.project.rejectedCharacters - ), - }); - }); - dispatch(setCharacterSet(characterSet)); + const allWords = getState().characterInventoryState.allWords; + const characters = new Set(); + allWords.forEach((w) => [...w].forEach((c) => characters.add(c))); + const { rejectedCharacters, validCharacters } = + getState().currentProjectState.project; + const entries: CharacterSetEntry[] = [...characters].map((c) => ({ + character: c, + occurrences: countOccurrences(c, allWords), + status: getCharacterStatus(c, validCharacters, rejectedCharacters), + })); + dispatch(setCharacterSet(entries)); }; } @@ -188,7 +160,10 @@ export function exit(): void { router.navigate(Path.Goals); } -function countCharacterOccurrences(char: string, words: string[]): number { +function countOccurrences(char: string, words: string[]): number { + if (char.length !== 1) { + console.error(`countOccurrences expects length 1 char, but got: ${char}`); + } let count = 0; for (const word of words) { for (const letter of word) { @@ -200,19 +175,13 @@ function countCharacterOccurrences(char: string, words: string[]): number { return count; } -function getChangesFromState(state: StoreState): CharacterChange[] { - const proj = state.currentProjectState.project; - const charInvState = state.characterInventoryState; - return getChanges(proj, charInvState); -} - export function getChanges( - proj: Project, + project: Project, charInvState: CharacterInventoryState ): CharacterChange[] { - const oldAcc = proj.validCharacters; + const oldAcc = project.validCharacters; const newAcc = charInvState.validCharacters; - const oldRej = proj.rejectedCharacters; + const oldRej = project.rejectedCharacters; const newRej = charInvState.rejectedCharacters; const allCharacters = [ ...new Set([...oldAcc, ...newAcc, ...oldRej, ...newRej]), @@ -261,10 +230,3 @@ function getChange( } return undefined; } - -function updateCurrentProject(state: StoreState): Project { - const project = state.currentProjectState.project; - project.validCharacters = state.characterInventoryState.validCharacters; - project.rejectedCharacters = state.characterInventoryState.rejectedCharacters; - return project; -} diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index afba0b6f91..f40f7b0ce6 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -1,119 +1,111 @@ +import { createSlice } from "@reduxjs/toolkit"; + import { - CharacterInventoryAction, - CharacterInventoryType, - CharacterInventoryState, - CharacterSetEntry, getCharacterStatus, + defaultState, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { StoreActionTypes } from "rootActions"; -export const defaultState: CharacterInventoryState = { - validCharacters: [], - rejectedCharacters: [], - allWords: [], - selectedCharacter: "", - characterSet: [], -}; +const characterInventorySlice = createSlice({ + name: "characterInventoryState", + initialState: defaultState, + reducers: { + addRejectedCharacterAction: (state, action) => { + if (!state.rejectedCharacters.includes(action.payload)) { + state.rejectedCharacters.push(action.payload); + } -export const characterInventoryReducer = ( - state: CharacterInventoryState = defaultState, - action: StoreAction | CharacterInventoryAction -): CharacterInventoryState => { - let validCharacters: string[]; - let rejectedCharacters: string[]; - let characterSet: CharacterSetEntry[]; - switch (action.type) { - case CharacterInventoryType.SET_VALID_CHARACTERS: - // Set prevents duplicate characters - validCharacters = [...new Set(action.payload)]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) - ); + const index = state.validCharacters.findIndex((c) => c == action.payload); + if (index !== -1) { + state.validCharacters.splice(index, 1); + } - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + const entry = state.characterSet.find( + (e) => e.character === action.payload + ); + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; + } + }, + addValidCharacterAction: (state, action) => { + if (!state.validCharacters.includes(action.payload)) { + state.validCharacters.push(action.payload); + } - case CharacterInventoryType.SET_REJECTED_CHARACTERS: - rejectedCharacters = [...new Set(action.payload)]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + const index = state.rejectedCharacters.findIndex( + (c) => c == action.payload ); + if (index !== -1) { + state.rejectedCharacters.splice(index, 1); + } - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + const entry = state.characterSet.find( + (e) => e.character === action.payload + ); + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_VALID_CHARACTERS: - validCharacters = [ - ...new Set(state.validCharacters.concat(action.payload)), - ]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) + } + }, + resetCharInvAction: () => defaultState, + setAllWordsAction: (state, action) => { + state.allWords = action.payload; + }, + setCharacterSetAction: (state, action) => { + if (action.payload) { + state.characterSet = action.payload; + } + }, + setRejectedCharactersAction: (state, action) => { + state.rejectedCharacters = [...new Set(action.payload as string[])]; + state.validCharacters = state.validCharacters.filter( + (char) => !state.rejectedCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS: - rejectedCharacters = [ - ...new Set(state.rejectedCharacters.concat(action.payload)), - ]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + } + }, + setSelectedCharacterAction: (state, action) => { + state.selectedCharacter = action.payload; + }, + setValidCharactersAction: (state, action) => { + state.validCharacters = [...new Set(action.payload as string[])]; + state.rejectedCharacters = state.rejectedCharacters.filter( + (char) => !state.validCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.SET_SELECTED_CHARACTER: - return { ...state, selectedCharacter: action.payload[0] }; - - case CharacterInventoryType.SET_ALL_WORDS: - return { ...state, allWords: action.payload }; - - case CharacterInventoryType.SET_CHARACTER_SET: - return action.characterSet - ? { ...state, characterSet: action.characterSet } - : state; - - case CharacterInventoryType.RESET: - return defaultState; + } + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); - case StoreActionTypes.RESET: - return defaultState; +export const { + addRejectedCharacterAction, + addValidCharacterAction, + resetCharInvAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} = characterInventorySlice.actions; - default: - return state; - } -}; +export default characterInventorySlice.reducer; diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts index f8850c9256..d1b7cdbe5f 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts @@ -1,17 +1,6 @@ import { CharacterStatus } from "goals/CharacterInventory/CharacterInventoryTypes"; -export enum CharacterInventoryType { - SET_VALID_CHARACTERS = "SET_VALID_CHARACTERS", - SET_REJECTED_CHARACTERS = "SET_REJECTED_CHARACTERS", - ADD_TO_VALID_CHARACTERS = "ADD_TO_VALID_CHARACTERS", - ADD_TO_REJECTED_CHARACTERS = "ADD_TO_REJECTED_CHARACTERS", - SET_ALL_WORDS = "SET_ALL_WORDS", - SET_SELECTED_CHARACTER = "SET_SELECTED_CHARACTER", - SET_CHARACTER_SET = "SET_CHARACTER_SET", - RESET = "CHAR_INV_RESET", -} - -// Utility function for returning a CharacterStatus from arrays of character data +/** Utility function for returning a CharacterStatus from arrays of character data */ export function getCharacterStatus( char: string, validChars: string[], @@ -26,12 +15,6 @@ export function getCharacterStatus( return CharacterStatus.Undecided; } -export interface CharacterInventoryAction { - type: CharacterInventoryType; - payload: string[]; - characterSet?: CharacterSetEntry[]; -} - export interface CharacterInventoryState { validCharacters: string[]; rejectedCharacters: string[]; @@ -40,8 +23,15 @@ export interface CharacterInventoryState { characterSet: CharacterSetEntry[]; } -/** A character with its occurrences and status, - * for sorting and filtering in a list */ +export const defaultState: CharacterInventoryState = { + validCharacters: [], + rejectedCharacters: [], + allWords: [], + selectedCharacter: "", + characterSet: [], +}; + +/** A character with its occurrences and status, for sorting and filtering in a list */ export interface CharacterSetEntry { character: string; occurrences: number; diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index 12ead70d14..1151c04b77 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -1,124 +1,248 @@ -import { Action } from "redux"; -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { Action, PreloadedState } from "redux"; import { Project } from "api/models"; -import { updateProject } from "backend"; +import { defaultState } from "components/App/DefaultState"; import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; -import * as Actions from "goals/CharacterInventory/Redux/CharacterInventoryActions"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { + fetchWords, + getAllCharacters, + getChanges, + loadCharInvData, + setCharacterStatus, + uploadInventory, +} from "goals/CharacterInventory/Redux/CharacterInventoryActions"; +import { + defaultState as defaultCharInvState, CharacterInventoryState, CharacterSetEntry, - CharacterInventoryType, - newCharacterSetEntry, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreState } from "types"; +import { RootState, setupStore } from "store"; import { newProject } from "types/project"; -import { newUser } from "types/user"; - -const VALID_DATA: string[] = ["a", "b"]; -const REJECT_DATA: string[] = ["y", "z"]; -const CHARACTER_SET_DATA: CharacterSetEntry[] = [ - { ...newCharacterSetEntry("a"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("b"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("y"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("z"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("m"), status: CharacterStatus.Undecided }, -]; - -const characterInventoryState: Partial = { - characterSet: CHARACTER_SET_DATA, - rejectedCharacters: REJECT_DATA, - validCharacters: VALID_DATA, -}; -const project: Partial = { - rejectedCharacters: [], - validCharacters: [], -}; -const MOCK_STATE = { - characterInventoryState, - currentProjectState: { project }, - goalsState: { currentGoal: { changes: {} } }, -}; - -const mockProjectId = "123"; -const mockUserEditId = "456"; -const mockUserId = "789"; -const mockUser = newUser(); -mockUser.id = mockUserId; -mockUser.workedProjects[mockProjectId] = mockUserEditId; +import { newWord } from "types/word"; -jest.mock("backend"); +jest.mock("backend", () => ({ + getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), +})); jest.mock("browserRouter"); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ asyncUpdateGoal: (...args: any[]) => mockAsyncUpdateGoal(...args), - addCharInvChangesToGoal: (...args: any[]) => mockAddCharInvChanges(...args), + addCharInvChangesToGoal: (...args: any[]) => + mockAddCharInvChangesToGoal(...args), +})); +jest.mock("components/Project/ProjectActions", () => ({ + asyncUpdateCurrentProject: (...args: any[]) => + mockAsyncUpdateCurrentProject(...args), })); + +const mockAddCharInvChangesToGoal = jest.fn(); +const mockAsyncUpdateCurrentProject = jest.fn(); const mockAsyncUpdateGoal = jest.fn(); -const mockAddCharInvChanges = jest.fn(); +const mockGetFrontierWords = jest.fn(); -const createMockStore = configureMockStore([thunk]); +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; beforeEach(() => { jest.resetAllMocks(); }); describe("CharacterInventoryActions", () => { - test("setInventory yields correct action", () => { - expect(Actions.setValidCharacters(VALID_DATA)).toEqual({ - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: VALID_DATA, + describe("setCharacterStatus", () => { + const character = "C"; + const mockState = (status: CharacterStatus): PreloadedState => { + const entry: CharacterSetEntry = { character, occurrences: 0, status }; + const rej = status === CharacterStatus.Rejected ? [character] : []; + const val = status === CharacterStatus.Accepted ? [character] : []; + return { + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + characterSet: [entry], + rejectedCharacters: rej, + validCharacters: val, + }, + }; + }; + + it("changes character from Rejected to Accepted", () => { + const store = setupStore(mockState(CharacterStatus.Rejected)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Accepted)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Accepted); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(1); + expect(state.validCharacters[0]).toEqual(character); + }); + + it("changes character from Accepted to Undecided", () => { + const store = setupStore(mockState(CharacterStatus.Accepted)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Undecided)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Undecided); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(0); + }); + + it("changes character from Undecided to Rejected", () => { + const store = setupStore(mockState(CharacterStatus.Undecided)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Rejected)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Rejected); + expect(state.rejectedCharacters).toHaveLength(1); + expect(state.rejectedCharacters[0]).toEqual(character); + expect(state.validCharacters).toHaveLength(0); }); }); - test("uploadInventory dispatches correct action", async () => { - // Mock out the goal-related things called by uploadInventory. - const mockAction: Action = { type: null }; - mockAsyncUpdateGoal.mockReturnValue(mockAction); - mockAddCharInvChanges.mockReturnValue(mockAction); - - const mockStore = createMockStore(MOCK_STATE); - const mockUpload = Actions.uploadInventory(); - await mockUpload( - mockStore.dispatch, - mockStore.getState as () => StoreState - ); - expect(updateProject).toHaveBeenCalledTimes(1); + describe("uploadInventory", () => { + it("dispatches no actions if there are no changes", async () => { + const store = setupStore(); + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).not.toHaveBeenCalled(); + expect(mockAsyncUpdateCurrentProject).not.toHaveBeenCalled(); + expect(mockAsyncUpdateGoal).not.toHaveBeenCalled(); + }); + + it("dispatches correct action if there are changes", async () => { + // Mock data with distinct characters + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + rejectedCharacters, + validCharacters, + }, + }); + + // Mock the dispatch functions called by uploadInventory. + const mockAction: Action = { type: null }; + mockAddCharInvChangesToGoal.mockReturnValue(mockAction); + mockAsyncUpdateCurrentProject.mockReturnValue(mockAction); + mockAsyncUpdateGoal.mockReturnValue(mockAction); + + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).toHaveBeenCalledTimes(1); + expect(mockAddCharInvChangesToGoal.mock.calls[0][0]).toHaveLength( + rejectedCharacters.length + validCharacters.length + ); + expect(mockAsyncUpdateCurrentProject).toHaveBeenCalledTimes(1); + const proj: Project = mockAsyncUpdateCurrentProject.mock.calls[0][0]; + expect(proj.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(proj.rejectedCharacters).toContain(c) + ); + expect(proj.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => expect(proj.validCharacters).toContain(c)); + expect(mockAsyncUpdateGoal).toHaveBeenCalledTimes(1); + }); }); - test("getChanges returns correct changes", () => { - const accAcc = "accepted"; - const accRej = "accepted->rejected"; - const accUnd = "accepted->undecided"; - const rejAcc = "rejected->accepted"; - const rejRej = "rejected"; - const rejUnd = "rejected->undecided"; - const undAcc = "undecided->accepted"; - const undRej = "undecided->rejected"; - const oldProj = { - ...newProject(), - validCharacters: [accAcc, accRej, accUnd], - rejectedCharacters: [rejAcc, rejRej, rejUnd], - }; - const charInvState: CharacterInventoryState = { - ...defaultState, - validCharacters: [accAcc, rejAcc, undAcc], - rejectedCharacters: [accRej, rejRej, undRej], - }; - const expectedChanges: CharacterChange[] = [ - [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], - [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], - [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], - [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], - [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], - [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], - ]; - const changes = Actions.getChanges(oldProj, charInvState); - expect(changes.length).toEqual(expectedChanges.length); - expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + describe("fetchWords", () => { + it("correctly affects state", async () => { + const store = setupStore(); + const verns = ["v1", "v2", "v3", "v4"]; + mockGetFrontierWords.mockResolvedValueOnce(verns.map((v) => newWord(v))); + await store.dispatch(fetchWords()); + const { allWords } = store.getState().characterInventoryState; + expect(allWords).toHaveLength(verns.length); + verns.forEach((v) => expect(allWords).toContain(v)); + }); + }); + + describe("getAllCharacters", () => { + it("correctly affects state", async () => { + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + // Words containing the characters 1 through 9 + allWords: ["123", "45246", "735111189"], + }, + }); + await store.dispatch(getAllCharacters()); + const { characterSet } = store.getState().characterInventoryState; + expect(characterSet).toHaveLength(9); + const chars = characterSet.map((char) => char.character); + [..."123456789"].forEach((c) => expect(chars).toContain(c)); + }); + }); + + describe("loadCharInvData", () => { + it("correctly affects state", async () => { + // Mock data with distinct characters + const mockVern = "1234"; + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { + ...persistedDefaultState.currentProjectState, + project: { ...newProject(), rejectedCharacters, validCharacters }, + }, + }); + mockGetFrontierWords.mockResolvedValueOnce([newWord(mockVern)]); + await store.dispatch(loadCharInvData()); + const state = store.getState().characterInventoryState; + + expect(state.allWords).toHaveLength(1); + expect(state.allWords[0]).toEqual(mockVern); + + expect(state.characterSet).toHaveLength(mockVern.length); + const chars = state.characterSet.map((char) => char.character); + [...mockVern].forEach((c) => expect(chars).toContain(c)); + + expect(state.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(state.rejectedCharacters).toContain(c) + ); + + expect(state.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => + expect(state.validCharacters).toContain(c) + ); + }); + }); + + describe("getChanges", () => { + it("returns correct changes", () => { + const accAcc = "accepted"; + const accRej = "accepted->rejected"; + const accUnd = "accepted->undecided"; + const rejAcc = "rejected->accepted"; + const rejRej = "rejected"; + const rejUnd = "rejected->undecided"; + const undAcc = "undecided->accepted"; + const undRej = "undecided->rejected"; + const oldProj = { + ...newProject(), + validCharacters: [accAcc, accRej, accUnd], + rejectedCharacters: [rejAcc, rejRej, rejUnd], + }; + const charInvState: CharacterInventoryState = { + ...defaultCharInvState, + validCharacters: [accAcc, rejAcc, undAcc], + rejectedCharacters: [accRej, rejRej, undRej], + }; + const expectedChanges: CharacterChange[] = [ + [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], + [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], + [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], + [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], + [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], + [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], + ]; + const changes = getChanges(oldProj, charInvState); + expect(changes.length).toEqual(expectedChanges.length); + expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + }); }); }); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx deleted file mode 100644 index ee94d75858..0000000000 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - characterInventoryReducer, - defaultState, -} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { - CharacterInventoryState, - CharacterInventoryAction, - CharacterInventoryType, -} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; - -const DATA: string[] = ["a", "b"]; -const BAD_RESP: string[] = ["If", "this", "appears", "there's", "an", "issue"]; - -describe("Test Character Inventory Reducer", () => { - it("Returns default state when passed no state", () => { - expect( - characterInventoryReducer(undefined, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(defaultState); - }); - - it("Returns a state with a specified inventory when passed an inventory", () => { - expect( - characterInventoryReducer(undefined, { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: DATA, - } as CharacterInventoryAction) - ).toEqual({ - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }); - }); - - it("Returns state passed in when passed an undefined action", () => { - const inv = { - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }; - expect( - characterInventoryReducer(inv, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(inv); - }); - - it("Returns default state when passed reset action", () => { - const action: StoreAction = { type: StoreActionTypes.RESET }; - - expect( - characterInventoryReducer({} as CharacterInventoryState, action) - ).toEqual(defaultState); - }); -}); diff --git a/src/rootReducer.ts b/src/rootReducer.ts index 272cab882a..709287dd1e 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -4,23 +4,23 @@ import goalsReducer from "components/GoalTimeline/Redux/GoalReducer"; import loginReducer from "components/Login/Redux/LoginReducer"; import projectReducer from "components/Project/ProjectReducer"; import exportProjectReducer from "components/ProjectExport/Redux/ExportProjectReducer"; -import { pronunciationsReducer } from "components/Pronunciations/Redux/PronunciationsReducer"; -import { treeViewReducer } from "components/TreeView/Redux/TreeViewReducer"; -import { characterInventoryReducer } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import pronunciationsReducer from "components/Pronunciations/Redux/PronunciationsReducer"; +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/ReviewEntriesComponent/Redux/ReviewEntriesReducer"; import { StoreState } from "types"; -import { analyticsReducer } from "types/Redux/analytics"; +import analyticsReducer from "types/Redux/analytics"; export const rootReducer: Reducer = combineReducers({ - //login + //login and signup loginState: loginReducer, //project currentProjectState: projectReducer, exportProjectState: exportProjectReducer, - //data entry and review entries + //data entry and review entries goal treeViewState: treeViewReducer, reviewEntriesState: reviewEntriesReducer, pronunciationsState: pronunciationsReducer, @@ -28,7 +28,7 @@ export const rootReducer: Reducer = combineReducers({ //goal timeline and current goal goalsState: goalsReducer, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: mergeDupStepReducer, //character inventory goal diff --git a/src/types/Redux/analytics.ts b/src/types/Redux/analytics.ts index 290bf04723..930d6af320 100644 --- a/src/types/Redux/analytics.ts +++ b/src/types/Redux/analytics.ts @@ -1,36 +1,30 @@ -import { - AnalyticsActionTypes, - AnalyticsChangePageAction, - AnalyticsState, - defaultState, -} from "types/Redux/analyticsReduxTypes"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -export function changePage(newPage: string): AnalyticsChangePageAction { - return { - type: AnalyticsActionTypes.ChangePage, - newPage, - }; -} +import { StoreActionTypes } from "rootActions"; +import { defaultState } from "types/Redux/analyticsReduxTypes"; -export const analyticsReducer = ( - //createStore() calls each reducer with undefined state - state: AnalyticsState = defaultState, - action: AnalyticsChangePageAction -): AnalyticsState => { - switch (action.type) { - case AnalyticsActionTypes.ChangePage: - if (action.newPage !== state.currentPage) { +const analyticsSlice = createSlice({ + name: "analyticsState", + initialState: defaultState, + reducers: { + changePageAction: (state, action) => { + if (action.payload !== state.currentPage) { analytics.track("navigate", { + destination: action.payload, source: state.currentPage, - destination: action.newPage, }); } - return { - ...state, - currentPage: action.newPage, - }; + state.currentPage = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +const { changePageAction } = analyticsSlice.actions; - default: - return state; - } -}; +export default analyticsSlice.reducer; + +export function changePage(newPage: string): PayloadAction { + return changePageAction(newPage); +} diff --git a/src/types/Redux/analyticsReduxTypes.ts b/src/types/Redux/analyticsReduxTypes.ts index a7210e11ac..036cd9b620 100644 --- a/src/types/Redux/analyticsReduxTypes.ts +++ b/src/types/Redux/analyticsReduxTypes.ts @@ -5,12 +5,3 @@ export interface AnalyticsState { export const defaultState: AnalyticsState = { currentPage: "", }; - -export enum AnalyticsActionTypes { - ChangePage = "CHANGE_CURRENT_PAGE", -} - -export interface AnalyticsChangePageAction { - type: AnalyticsActionTypes.ChangePage; - newPage: string; -}