From aa1f7eadbf261ff1c0d002a10972ceaba49a4a02 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 31 Oct 2023 17:00:57 -0400 Subject: [PATCH 1/2] Port Project to use redux-toolkit --- src/components/Project/ProjectActions.ts | 44 ++++---- src/components/Project/ProjectReducer.ts | 51 ++++----- src/components/Project/ProjectReduxTypes.ts | 19 ---- .../Project/tests/ProjectActions.test.tsx | 104 ++++++++++++++++++ .../Project/tests/ProjectReducer.test.tsx | 74 ------------- .../tests/CharacterInventoryActions.test.tsx | 5 - src/rootReducer.ts | 2 +- 7 files changed, 156 insertions(+), 143 deletions(-) create mode 100644 src/components/Project/tests/ProjectActions.test.tsx delete mode 100644 src/components/Project/tests/ProjectReducer.test.tsx diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index 70bd79f323..b04acc548e 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,34 +1,32 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { Project, User } from "api/models"; import { getAllProjectUsers, updateProject } from "backend"; import { setProjectId } from "backend/localStorage"; import { - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; + resetAction, + setProjectAction, + setUsersAction, +} from "components/Project/ProjectReducer"; import { StoreStateDispatch } from "types/Redux/actions"; +import { newProject } from "types/project"; -export function setCurrentProject(payload?: Project): ProjectAction { - return { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload, - }; +// Action Creation Functions + +export function resetCurrentProject(): Action { + return resetAction(); } -export function setCurrentProjectUsers(payload?: User[]): ProjectAction { - return { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS, - payload, - }; +export function setCurrentProject(project?: Project): PayloadAction { + return setProjectAction(project ?? newProject()); } -export function clearCurrentProject() { - return (dispatch: StoreStateDispatch) => { - setProjectId(); - dispatch(setCurrentProject()); - dispatch(setCurrentProjectUsers()); - }; +export function setCurrentProjectUsers(users?: User[]): PayloadAction { + return setUsersAction(users ?? []); } +// Dispatch Functions + export function asyncUpdateCurrentProject(project: Project) { return async (dispatch: StoreStateDispatch) => { await updateProject(project); @@ -36,12 +34,20 @@ export function asyncUpdateCurrentProject(project: Project) { }; } +/** Should only be called with projectId matching that in currentProjectState. */ export function asyncRefreshProjectUsers(projectId: string) { return async (dispatch: StoreStateDispatch) => { dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); }; } +export function clearCurrentProject() { + return (dispatch: StoreStateDispatch) => { + setProjectId(); + dispatch(resetCurrentProject()); + }; +} + export function setNewCurrentProject(project?: Project) { return (dispatch: StoreStateDispatch) => { setProjectId(project?.id); diff --git a/src/components/Project/ProjectReducer.ts b/src/components/Project/ProjectReducer.ts index 13aa66a8f1..7670d31c74 100644 --- a/src/components/Project/ProjectReducer.ts +++ b/src/components/Project/ProjectReducer.ts @@ -1,27 +1,28 @@ -import { - CurrentProjectState, - defaultState, - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newProject } from "types/project"; +import { createSlice } from "@reduxjs/toolkit"; -export const projectReducer = ( - state = defaultState, - action: ProjectAction | StoreAction -): CurrentProjectState => { - switch (action.type) { - case ProjectActionType.SET_CURRENT_PROJECT: - if (action.payload?.id === state.project.id) { - return { ...state, project: action.payload }; +import { defaultState } from "components/Project/ProjectReduxTypes"; +import { StoreActionTypes } from "rootActions"; + +const projectSlice = createSlice({ + name: "currentProjectState", + initialState: defaultState, + reducers: { + resetAction: () => defaultState, + setProjectAction: (state, action) => { + if (state.project.id !== action.payload.id) { + state.users = []; } - return { project: action.payload ?? newProject(), users: [] }; - case ProjectActionType.SET_CURRENT_PROJECT_USERS: - return { ...state, users: action.payload ?? [] }; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; + state.project = action.payload; + }, + setUsersAction: (state, action) => { + state.users = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { resetAction, setProjectAction, setUsersAction } = + projectSlice.actions; + +export default projectSlice.reducer; diff --git a/src/components/Project/ProjectReduxTypes.ts b/src/components/Project/ProjectReduxTypes.ts index 0fb02750e5..1ba4450825 100644 --- a/src/components/Project/ProjectReduxTypes.ts +++ b/src/components/Project/ProjectReduxTypes.ts @@ -1,25 +1,6 @@ import { Project, User } from "api/models"; import { newProject } from "types/project"; -export enum ProjectActionType { - SET_CURRENT_PROJECT = "SET_CURRENT_PROJECT", - SET_CURRENT_PROJECT_USERS = "SET_CURRENT_PROJECT_USERS", -} - -export interface SetCurrentProjectAction { - type: ProjectActionType.SET_CURRENT_PROJECT; - payload?: Project; -} - -export interface SetCurrentProjectUsersAction { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS; - payload?: User[]; -} - -export type ProjectAction = - | SetCurrentProjectAction - | SetCurrentProjectUsersAction; - export interface CurrentProjectState { project: Project; users: User[]; diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx new file mode 100644 index 0000000000..e92ade0ac4 --- /dev/null +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -0,0 +1,104 @@ +import { PreloadedState } from "redux"; + +import { Project } from "api/models"; +import { defaultState } from "components/App/DefaultState"; +import { + asyncUpdateCurrentProject, + asyncRefreshProjectUsers, + clearCurrentProject, + setNewCurrentProject, +} from "components/Project/ProjectActions"; +import { RootState, setupStore } from "store"; +import { newProject } from "types/project"; +import { newUser } from "types/user"; + +jest.mock("backend", () => ({ + getAllProjectUsers: (...args: any[]) => mockGetAllProjectUsers(...args), + updateProject: (...args: any[]) => mockUpdateProject(...args), +})); + +const mockGetAllProjectUsers = jest.fn(); +const mockUpdateProject = jest.fn(); +const mockProjId = "project-id"; + +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; + +describe("ProjectActions", () => { + describe("asyncUpdateCurrentProject", () => { + it("correctly affects state for different id", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [newUser()] }, + }); + const id = "new-id"; + await store.dispatch(asyncUpdateCurrentProject({ ...proj, id })); + expect(mockUpdateProject).toBeCalledTimes(1); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(id); + expect(users).toHaveLength(0); + }); + + it("correctly affects state for same id", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [newUser()] }, + }); + const name = "new-name"; + await store.dispatch(asyncUpdateCurrentProject({ ...proj, name })); + expect(mockUpdateProject).toBeCalledTimes(1); + const { project, users } = store.getState().currentProjectState; + expect(project.name).toEqual(name); + expect(users).toHaveLength(1); + }); + }); + + describe("asyncRefreshProjectUsers", () => { + it("correctly affects state", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [] }, + }); + const mockUsers = [newUser(), newUser(), newUser()]; + mockGetAllProjectUsers.mockResolvedValueOnce(mockUsers); + await store.dispatch(asyncRefreshProjectUsers("mockProjId")); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(mockProjId); + expect(users).toHaveLength(mockUsers.length); + }); + }); + + describe("clearCurrentProject", () => { + it("correctly affects state", () => { + const nonDefaultState = { + project: { ...newProject(), id: "nonempty-string" }, + users: [newUser()], + }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: nonDefaultState, + }); + store.dispatch(clearCurrentProject()); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(""); + expect(users).toHaveLength(0); + }); + }); + + describe("setNewCurrentProject", () => { + it("correctly affects state and doesn't update the backend", () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore(); + store.dispatch(setNewCurrentProject(proj)); + expect(mockUpdateProject).not.toBeCalled(); + const { project } = store.getState().currentProjectState; + expect(project.id).toEqual(mockProjId); + }); + }); +}); diff --git a/src/components/Project/tests/ProjectReducer.test.tsx b/src/components/Project/tests/ProjectReducer.test.tsx deleted file mode 100644 index 1c4cee05bc..0000000000 --- a/src/components/Project/tests/ProjectReducer.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { projectReducer } from "components/Project/ProjectReducer"; -import { - CurrentProjectState, - defaultState, - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newProject } from "types/project"; -import { newUser } from "types/user"; - -describe("ProjectReducer", () => { - it("returns default state when passed reset action", () => { - const action: StoreAction = { - type: StoreActionTypes.RESET, - }; - - expect(projectReducer({} as CurrentProjectState, action)).toEqual( - defaultState - ); - }); - - describe("SetCurrentProject", () => { - it("Preserves users when project id is the same", () => { - const id = "same"; - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: { ...newProject(), id }, - }; - - const user = newUser(); - expect( - projectReducer( - { project: { id }, users: [user] } as CurrentProjectState, - action - ) - ).toEqual({ project: action.payload, users: [user] }); - }); - - it("Resets users when project id changes", () => { - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: newProject(), - }; - - expect( - projectReducer( - { - project: { id: "different" }, - users: [newUser()], - } as CurrentProjectState, - action - ) - ).toEqual({ project: action.payload, users: [] }); - }); - }); - - describe("SetCurrentProjectUsers", () => { - it("Updates users; preserves project.", () => { - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS, - payload: [], - }; - - const id = "unique"; - expect( - projectReducer( - { project: { id }, users: [newUser()] } as CurrentProjectState, - action - ) - ).toEqual({ project: { id }, users: [] }); - }); - }); -}); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index eddf0a64f8..12ead70d14 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -4,7 +4,6 @@ import thunk from "redux-thunk"; import { Project } from "api/models"; import { updateProject } from "backend"; -import { ProjectActionType } from "components/Project/ProjectReduxTypes"; import { CharacterStatus, CharacterChange, @@ -89,10 +88,6 @@ describe("CharacterInventoryActions", () => { mockStore.getState as () => StoreState ); expect(updateProject).toHaveBeenCalledTimes(1); - expect(mockStore.getActions()).toContainEqual({ - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: project, - }); }); test("getChanges returns correct changes", () => { diff --git a/src/rootReducer.ts b/src/rootReducer.ts index b0863ed7f0..78483b2e47 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -2,7 +2,7 @@ import { combineReducers, Reducer } from "redux"; import goalsReducer from "components/GoalTimeline/Redux/GoalReducer"; import { loginReducer } from "components/Login/Redux/LoginReducer"; -import { projectReducer } from "components/Project/ProjectReducer"; +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"; From 32e8105e6afd4a01b6868ea474bc6308c6040a49 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 1 Nov 2023 13:55:07 -0400 Subject: [PATCH 2/2] Tidy --- src/components/Project/ProjectActions.ts | 11 +++++------ src/components/Project/tests/ProjectActions.test.tsx | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index b04acc548e..ba4348c53c 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -27,17 +27,16 @@ export function setCurrentProjectUsers(users?: User[]): PayloadAction { // Dispatch Functions -export function asyncUpdateCurrentProject(project: Project) { +export function asyncRefreshProjectUsers(projectId: string) { return async (dispatch: StoreStateDispatch) => { - await updateProject(project); - dispatch(setCurrentProject(project)); + dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); }; } -/** Should only be called with projectId matching that in currentProjectState. */ -export function asyncRefreshProjectUsers(projectId: string) { +export function asyncUpdateCurrentProject(project: Project) { return async (dispatch: StoreStateDispatch) => { - dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); + await updateProject(project); + dispatch(setCurrentProject(project)); }; } diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx index e92ade0ac4..b8fbd72593 100644 --- a/src/components/Project/tests/ProjectActions.test.tsx +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -3,8 +3,8 @@ import { PreloadedState } from "redux"; import { Project } from "api/models"; import { defaultState } from "components/App/DefaultState"; import { - asyncUpdateCurrentProject, asyncRefreshProjectUsers, + asyncUpdateCurrentProject, clearCurrentProject, setNewCurrentProject, } from "components/Project/ProjectActions"; @@ -29,7 +29,7 @@ const persistedDefaultState: PreloadedState = { describe("ProjectActions", () => { describe("asyncUpdateCurrentProject", () => { - it("correctly affects state for different id", async () => { + it("updates the backend and correctly affects state for different id", async () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState, @@ -43,7 +43,7 @@ describe("ProjectActions", () => { expect(users).toHaveLength(0); }); - it("correctly affects state for same id", async () => { + it("updates the backend and correctly affects state for same id", async () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState,