@@ -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;