diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index 70bd79f323..ba4348c53c 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,31 +1,35 @@ +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 asyncRefreshProjectUsers(projectId: string) { + return async (dispatch: StoreStateDispatch) => { + dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); }; } @@ -36,9 +40,10 @@ export function asyncUpdateCurrentProject(project: Project) { }; } -export function asyncRefreshProjectUsers(projectId: string) { - return async (dispatch: StoreStateDispatch) => { - dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); +export function clearCurrentProject() { + return (dispatch: StoreStateDispatch) => { + setProjectId(); + dispatch(resetCurrentProject()); }; } 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..b8fbd72593 --- /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 { + asyncRefreshProjectUsers, + asyncUpdateCurrentProject, + 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("updates the backend and 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("updates the backend and 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/rootReducer.ts b/src/rootReducer.ts index f9405cbbab..709287dd1e 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";