diff --git a/src/actions/projects.js b/src/actions/projects.js index 3bab941281..99d5aec068 100644 --- a/src/actions/projects.js +++ b/src/actions/projects.js @@ -33,6 +33,17 @@ const setProjectsError = ({ status, error }) => ({ error, }); +/** + * set archived projects in store, under their own key + * @param projects: archived projects + * @param status: status code + */ +const setArchivedProjectsSuccess = ({ projects, status }) => ({ + type: types.FETCH_ARCHIVED_PROJECTS_SUCCESS, + projects, + status, +}); + /** * Add new project to store * @param payload : new project @@ -115,7 +126,7 @@ export const fetchAllArchivedProjects = () => { const res = await axios.get(url); status = res.status; const projects = res.data; - dispatch(setProjectsSuccess({ projects, status })); + dispatch(setArchivedProjectsSuccess({ projects, status })); } catch (err) { status = err.response.status; error = err.response.data; diff --git a/src/components/Projects/Overview/Overview.jsx b/src/components/Projects/Overview/Overview.jsx index 9fd80d29ce..3a6c747bb2 100644 --- a/src/components/Projects/Overview/Overview.jsx +++ b/src/components/Projects/Overview/Overview.jsx @@ -4,10 +4,15 @@ * This component display the number of projects and active projects ********************************************************************************/ import React from 'react'; -import { TOTAL_PROJECTS, ACTIVE_PROJECTS } from './../../../languages/en/ui'; +import { TOTAL_PROJECTS, ACTIVE_PROJECTS, ARCHIVED_PROJECTS } from './../../../languages/en/ui'; import styles from "./Overview.module.css" const Overview = props => { + // The second card describes whichever list is on screen: active projects in + // the default view, archived projects once the archived view is opened. + const secondCardLabel = props.showArchived ? ARCHIVED_PROJECTS : ACTIVE_PROJECTS; + const secondCardCount = props.showArchived ? props.numberOfArchived : props.numberOfActive; + return (
@@ -21,7 +26,7 @@ const Overview = props => {
- {ACTIVE_PROJECTS}: {props.numberOfActive} + {secondCardLabel}: {secondCardCount}
diff --git a/src/components/Projects/Projects.jsx b/src/components/Projects/Projects.jsx index bfc9681a0a..591ad27a51 100644 --- a/src/components/Projects/Projects.jsx +++ b/src/components/Projects/Projects.jsx @@ -20,6 +20,10 @@ import Loading from '../common/Loading'; import hasPermission from '../../utils/permissions'; import EditableInfoModal from '../UserProfile/EditableModal/EditableInfoModal'; +// Stable reference for the empty case. Returning a fresh [] from a selector +// gives a new identity on every render, which retriggers effects that depend +// on it. +const EMPTY_PROJECT_LIST = []; const Projects = function(props) { const { role } = props.state.userProfile; @@ -28,9 +32,14 @@ const Projects = function(props) { const taskSelectionMode = location.state?.taskSelectionMode || false; const taskSelectionReturnPath = location.state?.returnPath || '/bmdashboard/AddNewTeam'; const allReduxProjects = useSelector(state => state.allProjects.projects); - const numberOfProjects = props.state.allProjects.projects.length; - const numberOfActive = props.state.allProjects.projects.filter(project => project.isActive) - .length; + const archivedReduxProjects = useSelector( + state => state.allProjects.archivedProjects ?? EMPTY_PROJECT_LIST, + ); + // Total counts every project the app knows about, so it does not change when + // the archived view is toggled. The second card switches between the active + // count and the archived count depending on which list is on screen. + const numberOfProjects = allReduxProjects.length + archivedReduxProjects.length; + const numberOfActive = allReduxProjects.filter(project => project.isActive).length; const { fetching, fetched, status, error } = props.state.allProjects; const initialModalData = { showModal: false, @@ -197,8 +206,8 @@ const Projects = function(props) { const generateProjectList = (categorySelectedForSort, showStatus, isShowingArchived) => { const activeMemberCounts = props.state.projectMembers?.activeMemberCounts || {}; - const filteredProjects = allReduxProjects - .filter(project => isShowingArchived ? project.isArchived : !project.isArchived) + const sourceProjects = isShowingArchived ? archivedReduxProjects : allReduxProjects; + const filteredProjects = sourceProjects .filter(project => { if (categorySelectedForSort && showStatus){ return project.category === categorySelectedForSort && project.isActive === showStatus; @@ -272,7 +281,10 @@ const Projects = function(props) { useEffect(() => { + // Both lists are loaded up front so the total is correct before the + // archived view is ever opened. props.fetchAllProjects(); + props.fetchAllArchivedProjects(); }, []); useEffect(() => { @@ -290,7 +302,7 @@ const Projects = function(props) { hasInactiveBtn: false, }); } - }, [categorySelectedForSort, showStatus, sorter, allReduxProjects, props.state.theme.darkMode, props.state.projectMembers?.activeMemberCounts, showArchived]); + }, [categorySelectedForSort, showStatus, sorter, allReduxProjects, archivedReduxProjects, props.state.theme.darkMode, props.state.projectMembers?.activeMemberCounts, showArchived]); useEffect(() => { const fetchProjects = async () => { @@ -299,11 +311,16 @@ const Projects = function(props) { return; } + // Search the same collection that is currently rendered. Archived projects + // live in their own reducer key, so searching allReduxProjects here would + // incorrectly return active projects while the archived view is open. + const visibleProjects = showArchived ? archivedReduxProjects : allReduxProjects; + // Mode 1: Search by user if (searchMode === 'person') { const userProjects = await props.getProjectsByUsersName(debouncedSearchName); - const filteredProjects = allReduxProjects.filter(p => + const filteredProjects = visibleProjects.filter(p => userProjects.includes(p._id) ); @@ -323,7 +340,7 @@ const Projects = function(props) { setProjectList(mapped); } else if (searchMode === 'project') { - const filteredProjects = allReduxProjects.filter(p => + const filteredProjects = visibleProjects.filter(p => p.projectName?.toLowerCase().includes(debouncedSearchName.toLowerCase()) ); @@ -346,7 +363,14 @@ const Projects = function(props) { }; fetchProjects(); -}, [debouncedSearchName, searchMode, allProjects, allReduxProjects]); +}, [ + debouncedSearchName, + searchMode, + allProjects, + allReduxProjects, + archivedReduxProjects, + showArchived, +]); const handleSearchName = searchNameInput => { setSearchName(searchNameInput); @@ -369,7 +393,12 @@ const Projects = function(props) { isPermissionPage={true} role={role} /> - + {canPostProject ? : null} {taskSelectionMode && (
@@ -407,7 +436,11 @@ const Projects = function(props) { onClick={handleFetchArchivedProjects} style={{ whiteSpace: 'nowrap', height: '38px', flexShrink: 0 }} className={`btn px-3 ${ - showArchived ? 'btn-warning' : darkMode ? 'btn-outline-light' : 'btn-outline-secondary' + darkMode + ? styles.archiveToggleDark + : showArchived + ? 'btn-warning' + : 'btn-outline-secondary' }`} > {showArchived ? 'Hide Archived' : 'Show Archived'} diff --git a/src/components/Projects/__tests__/Projects.test.jsx b/src/components/Projects/__tests__/Projects.test.jsx index 18260b8bab..5ea97dc999 100644 --- a/src/components/Projects/__tests__/Projects.test.jsx +++ b/src/components/Projects/__tests__/Projects.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent} from '@testing-library/react'; +import { act, render, screen, fireEvent} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import Projects from '..'; import { Provider } from 'react-redux'; @@ -214,5 +214,67 @@ describe("Projects component",()=>{ // fireEvent.click(closeButton) // expect(screen.queryByText('Confirm Archive')).not.toBeInTheDocument(); }) + + it('searches archived projects while the archived view is open', async () => { + axios.get.mockResolvedValue({ status: 200, data: [] }); + const activeProject = { ...projects[0], _id: 'active-project', projectName: 'Active Alpha' }; + const archivedProject = { + ...projects[0], + _id: 'archived-project', + projectName: 'Archived Alpha', + isArchived: true, + }; + const archivedStore = mockStore({ + ...store.getState(), + allProjects: { + projects: [activeProject], + archivedProjects: [archivedProject], + status: 200, + fetching: false, + fetched: true, + }, + }); + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Show Archived' })); + fireEvent.change(screen.getByLabelText('Filter by'), { target: { value: 'project' } }); + fireEvent.change(screen.getByPlaceholderText('Search by Project Name'), { + target: { value: 'Archived Alpha' }, + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 350)); + }); + + expect(screen.getByText('Archived Alpha')).toBeInTheDocument(); + expect(screen.queryByText('Active Alpha')).not.toBeInTheDocument(); + }); + + it('does not use the light Bootstrap button treatment in dark mode', () => { + axios.get.mockResolvedValue({ status: 200, data: [] }); + const darkStore = mockStore({ + ...store.getState(), + theme: { darkMode: true }, + }); + + render( + + + + + , + ); + + expect(screen.getByRole('button', { name: 'Show Archived' })).not.toHaveClass( + 'btn-outline-light', + ); + }); }) diff --git a/src/components/Projects/projects.module.css b/src/components/Projects/projects.module.css index dfe8a6aad4..0e0455def8 100644 --- a/src/components/Projects/projects.module.css +++ b/src/components/Projects/projects.module.css @@ -3,6 +3,19 @@ background: aliceblue; } +.archiveToggleDark { + color: #f8f9fa; + background-color: #2f415a; + border-color: #8fa3b8; +} + +.archiveToggleDark:hover, +.archiveToggleDark:focus { + color: #fff; + background-color: #405675; + border-color: #b8c7d6; +} + :global(#new_project) { margin-bottom: 10px; } @@ -229,4 +242,4 @@ tr:hover { } .searchLabelDark { background-color: #1f3b63 !important; -} \ No newline at end of file +} diff --git a/src/constants/projects.js b/src/constants/projects.js index 3abee2ebed..30560f27f7 100644 --- a/src/constants/projects.js +++ b/src/constants/projects.js @@ -8,6 +8,11 @@ export const FETCH_PROJECTS_START = 'FETCH_PROJECTS_START'; export const FETCH_PROJECTS_SUCCESS = 'FETCH_PROJECTS_SUCCESS'; export const FETCH_PROJECTS_ERROR = 'FETCH_PROJECTS_ERROR'; +// ARCHIVED PROJECTS +// Kept separate from FETCH_PROJECTS_SUCCESS so that loading the archived list +// does not overwrite `projects`, which several other components read. +export const FETCH_ARCHIVED_PROJECTS_SUCCESS = 'FETCH_ARCHIVED_PROJECTS_SUCCESS'; + // ADD NEW PROJECTS export const ADD_NEW_PROJECT = 'ADD_NEW_PROJECT'; export const ADD_NEW_PROJECT_ERROR = 'ADD_NEW_PROJECT_ERROR'; diff --git a/src/languages/en/ui.js b/src/languages/en/ui.js index a7293c20ea..6fe7b523a0 100644 --- a/src/languages/en/ui.js +++ b/src/languages/en/ui.js @@ -6,6 +6,7 @@ export const ACTIVE = 'Active'; export const TITLE = 'Title' export const INACTIVE = 'InActive'; export const ACTIVE_PROJECTS = 'Active Projects'; +export const ARCHIVED_PROJECTS = 'Archived Projects'; export const BM_DASHBOARD = 'BM Dashboard'; export const CP_DASHBOARD = 'CP Dashboard'; export const BM_PROJECT = 'Project'; diff --git a/src/reducers/__tests__/allProjectsReducer.test.js b/src/reducers/__tests__/allProjectsReducer.test.js index 6f51293d26..5dffae7402 100644 --- a/src/reducers/__tests__/allProjectsReducer.test.js +++ b/src/reducers/__tests__/allProjectsReducer.test.js @@ -6,6 +6,7 @@ describe('allProjectsReducer', () => { fetching: false, fetched: false, projects: [], + archivedProjects: [], status: 200, error: null, }; @@ -59,6 +60,40 @@ describe('allProjectsReducer', () => { expect(newState).toEqual(expectedState); }); + it('should handle FETCH_ARCHIVED_PROJECTS_SUCCESS without touching projects', () => { + const stateWithProjects = { + ...initialState, + projects: [{ _id: '1', isActive: true }], + }; + const archived = [{ _id: '2' }, { _id: '3' }]; + const action = { + type: types.FETCH_ARCHIVED_PROJECTS_SUCCESS, + projects: archived, + status: 200, + }; + const newState = allProjectsReducer(stateWithProjects, action); + + expect(newState.archivedProjects).toEqual(archived); + // The archived list must not overwrite `projects` — several other + // components read that key. + expect(newState.projects).toEqual(stateWithProjects.projects); + expect(newState.fetched).toBe(true); + expect(newState.fetching).toBe(false); + }); + + it('should keep archivedProjects when FETCH_PROJECTS_SUCCESS arrives', () => { + const stateWithArchived = { ...initialState, archivedProjects: [{ _id: '9' }] }; + const action = { + type: types.FETCH_PROJECTS_SUCCESS, + projects: [{ _id: '1' }], + status: 200, + }; + const newState = allProjectsReducer(stateWithArchived, action); + + expect(newState.projects).toEqual([{ _id: '1' }]); + expect(newState.archivedProjects).toEqual([{ _id: '9' }]); + }); + it('should handle ADD_NEW_PROJECT with successful status', () => { const newProject = { _id: 3, name: 'Project 3' }; const action = { diff --git a/src/reducers/allProjectsReducer.js b/src/reducers/allProjectsReducer.js index 083969692b..fec5c133d5 100644 --- a/src/reducers/allProjectsReducer.js +++ b/src/reducers/allProjectsReducer.js @@ -4,6 +4,10 @@ const allProjectsInital = { fetching: false, fetched: false, projects: [], + // Archived projects are held separately so that `projects` always means + // "not archived". The Projects page needs both counts at once to show a + // total that does not change when the archived view is toggled. + archivedProjects: [], status: 200, error: null, }; @@ -35,6 +39,15 @@ export const allProjectsReducer = (allProjects = allProjectsInital, action) => { }); } + case types.FETCH_ARCHIVED_PROJECTS_SUCCESS: { + return updateState({ + fetching: false, + fetched: true, + archivedProjects: action.projects, + status, + }); + } + case types.ADD_NEW_PROJECT: { if (status !== 201) return updateState({ status, error }); const { newProject } = action;