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 () => {