diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js
index 03b8af8af0..fc8083477a 100644
--- a/frontend/src/api/projects.js
+++ b/frontend/src/api/projects.js
@@ -39,7 +39,7 @@ export const useProjectsQuery = (fullProjectsQuery, action, queryOptions) => {
});
};
-export const useProjectQuery = (projectId) => {
+export const useProjectQuery = (projectId, otherOptions) => {
const token = useSelector((state) => state.auth.token);
const locale = useSelector((state) => state.preferences['locale']);
const fetchProject = ({ signal }) => {
@@ -51,6 +51,7 @@ export const useProjectQuery = (projectId) => {
return useQuery({
queryKey: ['project', projectId],
queryFn: fetchProject,
+ ...otherOptions,
});
};
export const useProjectSummaryQuery = (projectId, otherOptions = {}) => {
diff --git a/frontend/src/components/footer/index.js b/frontend/src/components/footer/index.js
index 0aa8118eac..e91613ee09 100644
--- a/frontend/src/components/footer/index.js
+++ b/frontend/src/components/footer/index.js
@@ -36,6 +36,8 @@ export function Footer() {
const footerDisabledPaths = [
'projects/:id/tasks',
+ 'projects/:id/instructions',
+ 'projects/:id/contributions',
'projects/:id/map',
'projects/:id/validate',
'projects/:id/live',
diff --git a/frontend/src/components/projectDetail/footer.js b/frontend/src/components/projectDetail/footer.js
index 05f8fe1910..1e66dbe6f6 100644
--- a/frontend/src/components/projectDetail/footer.js
+++ b/frontend/src/components/projectDetail/footer.js
@@ -1,5 +1,5 @@
import { useRef, Fragment } from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
@@ -58,6 +58,7 @@ const menuItems = [
export const ProjectDetailFooter = ({ className, projectId }) => {
const userIsloggedIn = useSelector((state) => state.auth.token);
const menuItemsContainerRef = useRef(null);
+ const { pathname } = useLocation();
return (
{
{userIsloggedIn &&
}
-
+
diff --git a/frontend/src/components/projectDetail/index.js b/frontend/src/components/projectDetail/index.js
index c40ab63ad0..3baaa17aad 100644
--- a/frontend/src/components/projectDetail/index.js
+++ b/frontend/src/components/projectDetail/index.js
@@ -1,5 +1,5 @@
-import { lazy, Suspense, useState } from 'react';
-import { Link } from 'react-router-dom';
+import { lazy, Suspense, useState, useEffect } from 'react';
+import { Link, useParams } from 'react-router-dom';
import ReactPlaceholder from 'react-placeholder';
import centroid from '@turf/centroid';
import { FormattedMessage } from 'react-intl';
@@ -35,9 +35,14 @@ import { ENABLE_EXPORT_TOOL } from '../../config/index.js';
/* lazy imports must be last import */
const ProjectTimeline = lazy(() => import('./timeline' /* webpackChunkName: "timeline" */));
-const ProjectDetailMap = (props) => {
+export const ProjectDetailMap = (props) => {
const [taskBordersOnly, setTaskBordersOnly] = useState(true);
+ useEffect(() => {
+ if (typeof props.taskBordersOnly !== 'boolean') return;
+ setTaskBordersOnly(props.taskBordersOnly);
+ }, [props.taskBordersOnly]);
+
const taskBordersGeoJSON = props.project.areaOfInterest && {
type: 'FeatureCollection',
features: [
@@ -139,6 +144,7 @@ export const ProjectDetailLeft = ({ project, contributors, className, type }) =>
export const ProjectDetail = (props) => {
useSetProjectPageTitleTag(props.project);
const size = useWindowSize();
+ const { id: projectId } = useParams();
const { data: contributors, status: contributorsStatus } = useProjectContributionsQuery(
props.project.projectId,
);
@@ -181,6 +187,12 @@ export const ProjectDetail = (props) => {
className="ph4 w-60-l w-80-m w-100 lh-title markdown-content blue-dark-abbey"
dangerouslySetInnerHTML={htmlDescription}
/>
+
+
+
@@ -435,6 +447,7 @@ ProjectDetailMap.propTypes = {
type: PropTypes.string,
tasksError: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
projectLoading: PropTypes.bool,
+ taskBordersOnly: PropTypes.bool,
};
ProjectDetailLeft.propTypes = {
diff --git a/frontend/src/components/projectDetail/messages.js b/frontend/src/components/projectDetail/messages.js
index cd457852c4..bd1826ad37 100644
--- a/frontend/src/components/projectDetail/messages.js
+++ b/frontend/src/components/projectDetail/messages.js
@@ -344,4 +344,8 @@ export default defineMessages({
id: 'project.noSimilarProjectsFound',
defaultMessage: 'Could not find any similar projects for this project',
},
+ viewProjectSpecificInstructions: {
+ id: 'project.viewProjectSpecificInstructions',
+ defaultMessage: 'View project specific instructions',
+ },
});
diff --git a/frontend/src/components/projectDetail/styles.scss b/frontend/src/components/projectDetail/styles.scss
index 41c4a8b923..405ba01659 100644
--- a/frontend/src/components/projectDetail/styles.scss
+++ b/frontend/src/components/projectDetail/styles.scss
@@ -36,3 +36,7 @@
.react-tooltip#dueDateBoxTooltip {
z-index: 999;
}
+
+.project-instructions-link {
+ letter-spacing: -0.0857513px;
+}
diff --git a/frontend/src/components/taskSelection/footer.js b/frontend/src/components/taskSelection/footer.js
index 03e6113dfe..821a4cfc21 100644
--- a/frontend/src/components/taskSelection/footer.js
+++ b/frontend/src/components/taskSelection/footer.js
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
import Popup from 'reactjs-popup';
import { FormattedMessage } from 'react-intl';
@@ -24,6 +24,7 @@ const TaskSelectionFooter = ({
setSelectedTasks,
}) => {
const navigate = useNavigate();
+ const { pathname } = useLocation();
const token = useSelector((state) => state.auth.token);
const locale = useSelector((state) => state.preferences.locale);
const [editor, setEditor] = useState(defaultUserEditor);
@@ -221,7 +222,20 @@ const TaskSelectionFooter = ({
-
-
-
-
-
+ ) : (
+
+
+
+
+ )}
diff --git a/frontend/src/components/taskSelection/tabSelector.js b/frontend/src/components/taskSelection/tabSelector.js
index fbc67be72f..c996258e7d 100644
--- a/frontend/src/components/taskSelection/tabSelector.js
+++ b/frontend/src/components/taskSelection/tabSelector.js
@@ -1,19 +1,25 @@
import { FormattedMessage } from 'react-intl';
+import { useSelector } from 'react-redux';
import messages from './messages';
-export const TabSelector = ({ activeSection, setActiveSection }) => (
-
- {['tasks', 'instructions', 'contributions'].map((section) => (
-
setActiveSection(section)}
- >
-
-
- ))}
-
-);
+export const TabSelector = ({ activeSection, setActiveSection }) => {
+ const token = useSelector((state) => state.auth.token);
+ const tabs = token ? ['tasks', 'instructions', 'contributions'] : ['instructions'];
+
+ return (
+
+ {tabs.map((section) => (
+
setActiveSection(section)}
+ >
+
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/components/taskSelection/tests/footer.test.js b/frontend/src/components/taskSelection/tests/footer.test.js
index 16bdf9d51c..7b165c8648 100644
--- a/frontend/src/components/taskSelection/tests/footer.test.js
+++ b/frontend/src/components/taskSelection/tests/footer.test.js
@@ -144,6 +144,7 @@ describe('Footer Lock Tasks', () => {
store.dispatch({ type: 'SET_PROJECT', project: null });
store.dispatch({ type: 'SET_LOCKED_TASKS', tasks: [] });
store.dispatch({ type: 'SET_TASKS_STATUS', status: null });
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
});
it('should display task cannot be locked for mapping message', async () => {
diff --git a/frontend/src/components/taskSelection/tests/index.test.js b/frontend/src/components/taskSelection/tests/index.test.js
index e3d8302e90..e069d95ed2 100644
--- a/frontend/src/components/taskSelection/tests/index.test.js
+++ b/frontend/src/components/taskSelection/tests/index.test.js
@@ -1,21 +1,45 @@
import '@testing-library/jest-dom';
-import { screen, act, waitFor } from '@testing-library/react';
+import { render, screen, act, waitFor } from '@testing-library/react';
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
import { QueryParamProvider } from 'use-query-params';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import userEvent from '@testing-library/user-event';
import { TaskSelection } from '..';
import { getProjectSummary } from '../../../network/tests/mockData/projects';
-import {
- QueryClientProviders,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
+import { QueryClientProviders, ReduxIntlProviders } from '../../../utils/testWithIntl';
import { store } from '../../../store';
describe('Contributions', () => {
+ const setup = () => {
+ return {
+ user: userEvent.setup(),
+ ...render(
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+ ,
+ ),
+ };
+ };
+
it('should select tasks mapped by the selected user', async () => {
act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: null });
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
store.dispatch({
type: 'SET_USER_DETAILS',
@@ -23,16 +47,7 @@ describe('Contributions', () => {
});
});
- const { user } = renderWithRouter(
-
-
-
-
-
-
- ,
- );
-
+ const { user } = setup();
await waitFor(() =>
expect(screen.getByText(/Project Specific Mapping Notes/i)).toBeInTheDocument(),
);
@@ -54,16 +69,7 @@ describe('Contributions', () => {
});
it('should select tasks validated by the selected user', async () => {
- const { user } = renderWithRouter(
-
-
-
-
-
-
- ,
- );
-
+ const { user } = setup();
await waitFor(() =>
expect(screen.getByText(/Project Specific Mapping Notes/i)).toBeInTheDocument(),
);
@@ -85,16 +91,7 @@ describe('Contributions', () => {
});
it('should sort tasks by their task number', async () => {
- const { user } = renderWithRouter(
-
-
-
-
-
-
- ,
- );
-
+ const { user } = setup();
await waitFor(() =>
expect(screen.getByText(/Project Specific Mapping Notes/i)).toBeInTheDocument(),
);
@@ -120,16 +117,7 @@ describe('Contributions', () => {
});
it('should clear text when close icon is clicked', async () => {
- const { user } = renderWithRouter(
-
-
-
-
-
-
- ,
- );
-
+ const { user } = setup();
await waitFor(() =>
expect(screen.getByText(/Project Specific Mapping Notes/i)).toBeInTheDocument(),
);
diff --git a/frontend/src/components/taskSelection/tests/tabSelector.test.js b/frontend/src/components/taskSelection/tests/tabSelector.test.js
index cafd75648a..2f22fc309d 100644
--- a/frontend/src/components/taskSelection/tests/tabSelector.test.js
+++ b/frontend/src/components/taskSelection/tests/tabSelector.test.js
@@ -1,14 +1,20 @@
import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { ReduxIntlProviders } from '../../../utils/testWithIntl';
import { TabSelector } from '../tabSelector';
-import userEvent from '@testing-library/user-event';
+import { store } from '../../../store';
describe('TabSelector component', () => {
const setActiveSection = jest.fn();
+ const user = userEvent.setup();
+
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ });
+
it('with the tasks tab active', async () => {
- const user = userEvent.setup();
const { container } = render(
@@ -23,8 +29,8 @@ describe('TabSelector component', () => {
await user.click(screen.getByText('Instructions'));
expect(setActiveSection).toHaveBeenCalledWith('instructions');
});
+
it('with the instructions tab active', async () => {
- const user = userEvent.setup();
render(
@@ -36,8 +42,8 @@ describe('TabSelector component', () => {
await user.click(screen.getByText('contributions'));
expect(setActiveSection).toHaveBeenLastCalledWith('contributions');
});
+
it('with the contributions tab active', async () => {
- const user = userEvent.setup();
render(
diff --git a/frontend/src/hooks/UseFilterContributors.js b/frontend/src/hooks/UseFilterContributors.js
index 4b9811c8c1..5e7789b074 100644
--- a/frontend/src/hooks/UseFilterContributors.js
+++ b/frontend/src/hooks/UseFilterContributors.js
@@ -6,7 +6,7 @@ export function useFilterContributors(contributors, level, username) {
const [filteredContributors, setFilter] = useState([]);
useEffect(() => {
- let users = contributors;
+ let users = contributors || [];
if (['ADVANCED', 'INTERMEDIATE', 'BEGINNER'].includes(level)) {
users = users.filter((user) => user.mappingLevel === level);
}
diff --git a/frontend/src/routes.js b/frontend/src/routes.js
index 7495186182..3647b56f87 100644
--- a/frontend/src/routes.js
+++ b/frontend/src/routes.js
@@ -54,7 +54,7 @@ export const router = createBrowserRouter(
}}
/>
{
const { SelectTask } = await import(
'./views/taskSelection' /* webpackChunkName: "taskSelection" */
diff --git a/frontend/src/views/taskSelection.js b/frontend/src/views/taskSelection.js
index 2b8cfe7f9b..7946bfde65 100644
--- a/frontend/src/views/taskSelection.js
+++ b/frontend/src/views/taskSelection.js
@@ -1,35 +1,63 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { TaskSelection } from '../components/taskSelection';
import { NotFound } from './notFound';
-import { useProjectSummaryQuery } from '../api/projects';
+import { useProjectSummaryQuery, useProjectQuery } from '../api/projects';
import { Preloader } from '../components/preloader';
+import PrivateProjectError from '../components/projectDetail/privateProjectError';
+
+const publicRoutes = ['/instructions'];
export function SelectTask() {
const { id } = useParams();
+ const { pathname } = useLocation();
const navigate = useNavigate();
const token = useSelector((state) => state.auth.token);
- const { data, status, error } = useProjectSummaryQuery(id, {
+ const {
+ data: projectSummaryData,
+ error: projectSummaryError,
+ status: projectSummaryStatus,
+ } = useProjectSummaryQuery(id, {
useErrorBoundary: (error) => error.response.status !== 404,
+ enabled: !!token,
+ });
+ const {
+ data: projectData,
+ error: projectError,
+ status: projectStatus,
+ } = useProjectQuery(id, {
+ enabled: !token,
});
useEffect(() => {
- if (!token) {
+ const isPublicRoute = publicRoutes.some((url) => pathname.includes(url));
+ if (!isPublicRoute && !token) {
navigate('/login');
}
- }, [navigate, token]);
+ }, [navigate, token, pathname]);
+
+ const status = token ? projectSummaryStatus : projectStatus;
+ const error = token ? projectSummaryError : projectError;
if (status === 'loading') {
return ;
}
if (status === 'error') {
- if (error.response.status === 404) {
- return ;
- }
+ return (
+ <>
+ {error.response.data.SubCode === 'PrivateProject' ? (
+
+ ) : (
+
+ )}
+ >
+ );
}
- return ;
+ const project = token ? projectSummaryData : projectData?.data;
+
+ return ;
}
diff --git a/frontend/src/views/tests/taskSelection.test.js b/frontend/src/views/tests/taskSelection.test.js
index 1d5c53eb0f..c81d0f1a88 100644
--- a/frontend/src/views/tests/taskSelection.test.js
+++ b/frontend/src/views/tests/taskSelection.test.js
@@ -17,10 +17,12 @@ describe('Task Selection Page', () => {
return {
user: userEvent.setup(),
...render(
-
+
@@ -54,6 +56,7 @@ describe('Task Selection Page', () => {
userDetails: { id: 69, username: 'user_3', isExpert: false, role: 'READ_ONLY' },
});
store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
setup();
@@ -72,6 +75,7 @@ describe('Task Selection Page', () => {
userDetails: { id: 69, username: 'user_3', isExpert: false },
});
store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
setup();
@@ -84,6 +88,7 @@ describe('Task Selection Page', () => {
type: 'SET_USER_DETAILS',
userDetails: { id: 69, username: 'user_3', isExpert: true },
});
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
});
setup();
await waitFor(() => expect(screen.getByRole('link')).toBeInTheDocument());
@@ -93,6 +98,10 @@ describe('Task Selection Page', () => {
});
it('should change the button text to map selected task when user selects a task', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
const { user } = setup();
await screen.findAllByText(/last updated by/i);
expect(
@@ -130,6 +139,10 @@ describe('Task Selection Page', () => {
});
it('should change the button text to map another task when user selects a task for validation but the user level is not met', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
const { user } = setup();
await screen.findAllByText(/last updated by/i);
// Selecting a task that is available for validation
@@ -151,6 +164,8 @@ describe('Task Selection Page', () => {
type: 'SET_USER_DETAILS',
userDetails: { id: 69, username: 'user_3', isExpert: true, role: 'ADMIN' },
});
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
const { user } = setup();
await screen.findAllByText(/last updated by/i);
@@ -200,6 +215,10 @@ describe('Task Selection Page', () => {
});
it('should filter the task list by search query', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
const { user } = setup();
await screen.findAllByText(/last updated by/i);
expect(
@@ -219,6 +238,10 @@ describe('Task Selection Page', () => {
});
it('should navigate to the contributions tab', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
const { user } = setup();
await screen.findAllByText(/last updated by/i);
await user.click(
@@ -239,10 +262,10 @@ describe('Random Task Selection', () => {
return {
user: userEvent.setup(),
...render(
-
+
@@ -265,6 +288,8 @@ describe('Random Task Selection', () => {
type: 'SET_USER_DETAILS',
userDetails: { id: 69, username: 'user_3', isExpert: true },
});
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
const { user } = setup();
await screen.findAllByText(/last updated by/i);
@@ -293,8 +318,8 @@ describe('Random Task Selection', () => {
userDetails: { id: 69, username: 'user_3', isExpert: false },
});
store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
-
setup();
expect(await screen.findByText(/Project Specific Mapping Notes/i)).toBeInTheDocument();
expect(
@@ -309,7 +334,7 @@ describe('Complete Project', () => {
@@ -325,6 +350,10 @@ describe('Complete Project', () => {
);
it('should display button to select another project', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
setup();
await screen.findAllByText(/last updated by/i);
expect(
@@ -341,7 +370,7 @@ describe('Mapped Project', () => {
@@ -363,6 +392,8 @@ describe('Mapped Project', () => {
userDetails: { id: 69, username: 'user_3', isExpert: false, role: 'ADMIN' },
});
});
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
setup();
await screen.findAllByText(/last updated by/i);
expect(
@@ -379,7 +410,7 @@ describe('Resume Mapping', () => {
@@ -400,6 +431,8 @@ describe('Resume Mapping', () => {
type: 'SET_USER_DETAILS',
userDetails: { id: 69, username: 'Patrik_B', isExpert: false, role: 'ADMIN' },
});
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
});
setup();
await screen.findAllByText(/last updated by/i);
@@ -425,7 +458,7 @@ test('it should pre select task from the list from URL params', async () => {