-
-
100 ? 100 : firstBarValue}%`,
- height,
- borderRadius: '6px',
- }}
- role="progressbar"
- aria-valuenow={firstBarValue}
- aria-valuemin="0"
- aria-valuemax="100"
- />
-
100 ? 100 : secondBarValue}%`,
- height,
- borderRadius: '6px',
- }}
- role="progressbar"
- aria-valuenow={secondBarValue}
- aria-valuemin="0"
- aria-valuemax="100"
- />
-
-
-
- );
-};
-
-export default function PartnersProgresBar({ data, totalData, percentValidated, label, value }) {
- return (
-
- );
-}
diff --git a/frontend/src/components/partners/partnersProgresBar.jsx b/frontend/src/components/partners/partnersProgresBar.jsx
new file mode 100644
index 0000000000..0e3a0d348b
--- /dev/null
+++ b/frontend/src/components/partners/partnersProgresBar.jsx
@@ -0,0 +1,72 @@
+import { OHSOME_STATS_BASE_URL } from '../../config';
+
+const height = '1.65rem';
+
+const ProgressBar = ({ className, firstBarValue, secondBarValue = 0, data }) => {
+ return (
+
+
+
+
100 ? 100 : firstBarValue}%`,
+ height,
+ borderRadius: '6px',
+ }}
+ role="progressbar"
+ aria-valuenow={firstBarValue}
+ aria-valuemin="0"
+ aria-valuemax="100"
+ />
+
100 ? 100 : secondBarValue}%`,
+ height,
+ borderRadius: '6px',
+ }}
+ role="progressbar"
+ aria-valuenow={secondBarValue}
+ aria-valuemin="0"
+ aria-valuemax="100"
+ />
+
+
+
+ );
+};
+
+export default function PartnersProgresBar({ data, percentValidated }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/partners/partnersResources.js b/frontend/src/components/partners/partnersResources.jsx
similarity index 100%
rename from frontend/src/components/partners/partnersResources.js
rename to frontend/src/components/partners/partnersResources.jsx
diff --git a/frontend/src/components/partners/partnersStats.js b/frontend/src/components/partners/partnersStats.js
deleted file mode 100644
index 8724511215..0000000000
--- a/frontend/src/components/partners/partnersStats.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-import shortNumber from 'short-number';
-
-import messages from './messages';
-import { StatsCard } from '../statsCard';
-import { MappingIcon, EditIcon, RoadIcon, HomeIcon } from '../svgIcons';
-
-const iconClass = 'h-50 w-50';
-const iconStyle = { height: '45px' };
-
-export const StatsNumber = (props) => {
- const value = shortNumber(props.value);
- if (typeof value === 'number') {
- return (
-
-
-
- );
- }
- return (
-
-
- {value.substr(-1)}
-
- );
-};
-
-export const StatsColumn = ({ label, value, icon }: Object) => {
- return (
-
-
{icon}
-
-
- {value !== undefined ? : <>–>}
-
-
-
-
-
-
- );
-};
-
-export const StatsSection = ({ partner }) => {
- return (
-
-
- }
- description={ }
- value={partner ? partner.users : '-'}
- className={'w-25-l w-50-m w-100 mv1'}
- />
- }
- description={ }
- value={partner ? partner.edits : '-'}
- className={'w-25-l w-50-m w-100 mv1'}
- />
- }
- description={ }
- value={partner ? partner.buildings : '-'}
- className={'w-25-l w-50-m w-100 mv1'}
- />
- }
- description={ }
- value={partner ? partner.roads : '-'}
- className={'w-25-l w-50-m w-100 mv1'}
- />
-
-
- );
-};
diff --git a/frontend/src/components/partners/partnersStats.jsx b/frontend/src/components/partners/partnersStats.jsx
new file mode 100644
index 0000000000..687a31776b
--- /dev/null
+++ b/frontend/src/components/partners/partnersStats.jsx
@@ -0,0 +1,75 @@
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+import shortNumber from 'short-number';
+
+import messages from './messages';
+import { StatsCard } from '../statsCard';
+import { MappingIcon, EditIcon, RoadIcon, HomeIcon } from '../svgIcons';
+
+const iconClass = 'h-50 w-50';
+const iconStyle = { height: '45px' };
+
+export const StatsNumber = (props) => {
+ const value = shortNumber(props.value);
+ if (typeof value === 'number') {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+ {value.substr(-1)}
+
+ );
+};
+
+export const StatsColumn = ({ label, value, icon }) => {
+ return (
+
+
{icon}
+
+
+ {value !== undefined ? : <>–>}
+
+
+
+
+
+
+ );
+};
+
+export const StatsSection = ({ partner }) => {
+ return (
+
+
+ }
+ description={ }
+ value={partner ? partner.users : '-'}
+ className={'w-25-l w-50-m w-100 mv1'}
+ />
+ }
+ description={ }
+ value={partner ? partner.edits : '-'}
+ className={'w-25-l w-50-m w-100 mv1'}
+ />
+ }
+ description={ }
+ value={partner ? partner.buildings : '-'}
+ className={'w-25-l w-50-m w-100 mv1'}
+ />
+ }
+ description={ }
+ value={partner ? partner.roads : '-'}
+ className={'w-25-l w-50-m w-100 mv1'}
+ />
+
+
+ );
+};
diff --git a/frontend/src/components/portal.js b/frontend/src/components/portal.jsx
similarity index 100%
rename from frontend/src/components/portal.js
rename to frontend/src/components/portal.jsx
diff --git a/frontend/src/components/preloader.js b/frontend/src/components/preloader.tsx
similarity index 100%
rename from frontend/src/components/preloader.js
rename to frontend/src/components/preloader.tsx
diff --git a/frontend/src/components/progressBar.js b/frontend/src/components/progressBar.jsx
similarity index 100%
rename from frontend/src/components/progressBar.js
rename to frontend/src/components/progressBar.jsx
diff --git a/frontend/src/components/projectCard/dueDateBox.js b/frontend/src/components/projectCard/dueDateBox.js
deleted file mode 100644
index 5370ab996d..0000000000
--- a/frontend/src/components/projectCard/dueDateBox.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useState, useEffect } from 'react';
-import { FormattedMessage, useIntl } from 'react-intl';
-import humanizeDuration from 'humanize-duration';
-import { Tooltip } from 'react-tooltip';
-
-import { ClockIcon } from '../svgIcons';
-import messages from './messages';
-import { TimerIcon } from '../svgIcons/timer';
-
-export function DueDateBox({
- dueDate,
- intervalMili,
- tooltipMsg,
- isTaskStatusPage = false,
- isProjectDetailPage = false,
-}: Object) {
- const intl = useIntl();
- const [timer, setTimer] = useState(Date.now());
- useEffect(() => {
- let interval;
- if (intervalMili) {
- interval = setInterval(() => {
- setTimer(Date.now());
- }, intervalMili); // 1 minute
- }
- return () => {
- clearInterval(interval);
- };
- }, [intervalMili]);
-
- if (dueDate === undefined || new Date(dueDate) === undefined) {
- return null;
- }
-
- let options = { language: intl.locale.slice(0, 2), fallbacks: ['en'], largest: 1 };
-
- if (intervalMili !== undefined) {
- options = { units: ['h', 'm'], round: true };
- }
- const milliDifference = new Date(dueDate) - timer;
-
- if (milliDifference > 0) {
- return (
- <>
-
- {!isTaskStatusPage ? (
-
- ) : (
-
- )}
-
-
-
-
- {tooltipMsg &&
}
- >
- );
- } else {
- return (
- isProjectDetailPage && (
-
-
-
- {dueDate ? (
-
- ) : (
-
- )}
-
-
- )
- );
- }
-}
diff --git a/frontend/src/components/projectCard/dueDateBox.jsx b/frontend/src/components/projectCard/dueDateBox.jsx
new file mode 100644
index 0000000000..48ca00a8e8
--- /dev/null
+++ b/frontend/src/components/projectCard/dueDateBox.jsx
@@ -0,0 +1,91 @@
+import { useState, useEffect } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import humanizeDuration from 'humanize-duration';
+import { Tooltip } from 'react-tooltip';
+
+import { ClockIcon } from '../svgIcons';
+import messages from './messages';
+import { TimerIcon } from '../svgIcons/timer';
+
+export function DueDateBox({
+ dueDate,
+ intervalMili,
+ tooltipMsg,
+ isTaskStatusPage = false,
+ isProjectDetailPage = false,
+}) {
+ const intl = useIntl();
+ const [timer, setTimer] = useState(Date.now());
+ useEffect(() => {
+ let interval;
+ if (intervalMili) {
+ interval = setInterval(() => {
+ setTimer(Date.now());
+ }, intervalMili); // 1 minute
+ }
+ return () => {
+ clearInterval(interval);
+ };
+ }, [intervalMili]);
+
+ if (dueDate === undefined || new Date(dueDate) === undefined) {
+ return null;
+ }
+
+ let options = { language: intl.locale.slice(0, 2), fallbacks: ['en'], largest: 1 };
+
+ if (intervalMili !== undefined) {
+ options = { units: ['h', 'm'], round: true };
+ }
+ const milliDifference = new Date(dueDate) - timer;
+
+ if (milliDifference > 0) {
+ return (
+ <>
+
+ {!isTaskStatusPage ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {tooltipMsg &&
}
+ >
+ );
+ } else {
+ return (
+ isProjectDetailPage && (
+
+
+
+ {dueDate ? (
+
+ ) : (
+
+ )}
+
+
+ )
+ );
+ }
+}
diff --git a/frontend/src/components/projectCard/messages.js b/frontend/src/components/projectCard/messages.ts
similarity index 100%
rename from frontend/src/components/projectCard/messages.js
rename to frontend/src/components/projectCard/messages.ts
diff --git a/frontend/src/components/projectCard/nCardPlaceholder.js b/frontend/src/components/projectCard/nCardPlaceholder.jsx
similarity index 100%
rename from frontend/src/components/projectCard/nCardPlaceholder.js
rename to frontend/src/components/projectCard/nCardPlaceholder.jsx
diff --git a/frontend/src/components/projectCard/priorityBox.js b/frontend/src/components/projectCard/priorityBox.js
deleted file mode 100644
index 6a28b7f4f2..0000000000
--- a/frontend/src/components/projectCard/priorityBox.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { ClockIcon } from '../svgIcons';
-
-export function PriorityBox({ priority, extraClasses, showIcon }: Object) {
- let color = 'blue-grey b--blue-grey';
- if (priority === 'URGENT') color = 'red b--red';
- if (priority === 'HIGH') color = 'orange b--orange';
-
- return (
-
- {(msg) => (
-
- {showIcon && }
- {priority ? (
-
-
-
- ) : (
- ''
- )}
-
- )}
-
- );
-}
diff --git a/frontend/src/components/projectCard/priorityBox.jsx b/frontend/src/components/projectCard/priorityBox.jsx
new file mode 100644
index 0000000000..5734c39029
--- /dev/null
+++ b/frontend/src/components/projectCard/priorityBox.jsx
@@ -0,0 +1,27 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { ClockIcon } from '../svgIcons';
+
+export function PriorityBox({ priority, extraClasses, showIcon }) {
+ let color = 'blue-grey b--blue-grey';
+ if (priority === 'URGENT') color = 'red b--red';
+ if (priority === 'HIGH') color = 'orange b--orange';
+
+ return (
+
+ {(msg) => (
+
+ {showIcon && }
+ {priority ? (
+
+
+
+ ) : (
+ ''
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/projectCard/projectCard.js b/frontend/src/components/projectCard/projectCard.js
deleted file mode 100644
index e87de365b3..0000000000
--- a/frontend/src/components/projectCard/projectCard.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import { useState } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { Link } from 'react-router-dom';
-
-import messages from './messages';
-import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
-import ProjectProgressBar from './projectProgressBar';
-import { DifficultyMessage } from '../mappingLevel';
-import { ProjectStatusBox } from '../projectDetail/statusBox';
-import { PROJECTCARD_CONTRIBUTION_SHOWN_THRESHOLD } from '../../config/index';
-import { PriorityBox } from './priorityBox';
-import { DueDateBox } from './dueDateBox';
-import './styles.scss';
-
-export function ProjectTeaser({
- lastUpdated,
- totalContributors,
- className,
- littleFont = 'f7',
- bigFont = 'f6',
-}: Object) {
- /* outerDivStyles must have f6 even if sub-divs have f7 to fix grid issues*/
- const outerDivStyles = 'f6 tl blue-grey truncate mb2';
-
- if (totalContributors < PROJECTCARD_CONTRIBUTION_SHOWN_THRESHOLD) {
- return (
-
-
- {' '}
-
-
-
- );
- } else {
- return (
-
-
- {totalContributors || 0} ,
- }}
- />
-
-
- );
- }
-}
-
-export function ProjectCard({
- projectId,
- name,
- shortDescription,
- organisationName,
- organisationLogo,
- lastUpdated,
- dueDate,
- priority,
- status,
- difficulty,
- campaignTag,
- percentMapped,
- percentValidated,
- totalContributors,
- showBottomButtons = false,
-}: Object) {
- const [isHovered, setHovered] = useState(false);
- const linkCombo = 'link pa2 f6 ba b--grey-light di w-50 truncate tc';
-
- const showBottomButtonsHovered = showBottomButtons === true ? isHovered : false;
- const bottomButtonSpacer = showBottomButtons ? 'pt3 pb4' : 'pv3';
- const bottomButtonMargin = showBottomButtons ? 'project-card-with-btn' : 'project-card';
-
- const bottomButtons = (
-
-
-
-
-
-
-
-
- );
-
- return (
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- className={`relative blue-dark`}
- >
-
-
-
-
-
-
-
-
- {['DRAFT', 'ARCHIVED'].includes(status) ? (
-
- ) : (
-
- )}
-
-
-
-
#{projectId}
-
- {name}
-
-
-
- {shortDescription} {campaignTag ? ' · ' + campaignTag : ''}
-
-
-
-
-
-
-
- {showBottomButtonsHovered && bottomButtons}
-
- );
-}
diff --git a/frontend/src/components/projectCard/projectCard.jsx b/frontend/src/components/projectCard/projectCard.jsx
new file mode 100644
index 0000000000..601fadf442
--- /dev/null
+++ b/frontend/src/components/projectCard/projectCard.jsx
@@ -0,0 +1,150 @@
+import { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+import messages from './messages';
+import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
+import ProjectProgressBar from './projectProgressBar';
+import { DifficultyMessage } from '../mappingLevel';
+import { ProjectStatusBox } from '../projectDetail/statusBox';
+import { PROJECTCARD_CONTRIBUTION_SHOWN_THRESHOLD } from '../../config/index';
+import { PriorityBox } from './priorityBox';
+import { DueDateBox } from './dueDateBox';
+import './styles.scss';
+
+export function ProjectTeaser({
+ lastUpdated,
+ totalContributors,
+ className,
+ littleFont = 'f7',
+ bigFont = 'f6',
+}) {
+ /* outerDivStyles must have f6 even if sub-divs have f7 to fix grid issues*/
+ const outerDivStyles = 'f6 tl blue-grey truncate mb2';
+
+ if (totalContributors < PROJECTCARD_CONTRIBUTION_SHOWN_THRESHOLD) {
+ return (
+
+
+ {' '}
+
+
+
+ );
+ } else {
+ return (
+
+
+ {totalContributors || 0} ,
+ }}
+ />
+
+
+ );
+ }
+}
+
+export function ProjectCard({
+ projectId,
+ name,
+ shortDescription,
+ organisationName,
+ organisationLogo,
+ lastUpdated,
+ dueDate,
+ priority,
+ status,
+ difficulty,
+ campaignTag,
+ percentMapped,
+ percentValidated,
+ totalContributors,
+ showBottomButtons = false,
+}) {
+ const [isHovered, setHovered] = useState(false);
+ const linkCombo = 'link pa2 f6 ba b--grey-light di w-50 truncate tc';
+
+ const showBottomButtonsHovered = showBottomButtons === true ? isHovered : false;
+ const bottomButtonSpacer = showBottomButtons ? 'pt3 pb4' : 'pv3';
+ const bottomButtonMargin = showBottomButtons ? 'project-card-with-btn' : 'project-card';
+
+ const bottomButtons = (
+
+
+
+
+
+
+
+
+ );
+
+ return (
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ className={`relative blue-dark`}
+ >
+
+
+
+
+
+
+
+
+ {['DRAFT', 'ARCHIVED'].includes(status) ? (
+
+ ) : (
+
+ )}
+
+
+
+
#{projectId}
+
+ {name}
+
+
+
+ {shortDescription} {campaignTag ? ' · ' + campaignTag : ''}
+
+
+
+
+
+
+
+ {showBottomButtonsHovered && bottomButtons}
+
+ );
+}
diff --git a/frontend/src/components/projectCard/projectProgressBar.js b/frontend/src/components/projectCard/projectProgressBar.js
deleted file mode 100644
index d16d69e000..0000000000
--- a/frontend/src/components/projectCard/projectProgressBar.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { ProgressBar } from '../progressBar';
-
-export default function ProjectProgressBar({
- percentMapped,
- percentValidated,
- percentBadImagery,
- className,
- small = true,
-}: Object) {
- return (
- <>
-
-
- {percentMapped} }}
- />
-
-
- {percentValidated} }}
- />
-
- {![null, undefined].includes(percentBadImagery) && (
-
- {percentBadImagery} }}
- />
-
- )}
-
- >
- );
-}
diff --git a/frontend/src/components/projectCard/projectProgressBar.jsx b/frontend/src/components/projectCard/projectProgressBar.jsx
new file mode 100644
index 0000000000..1d22573996
--- /dev/null
+++ b/frontend/src/components/projectCard/projectProgressBar.jsx
@@ -0,0 +1,45 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { ProgressBar } from '../progressBar';
+
+export default function ProjectProgressBar({
+ percentMapped,
+ percentValidated,
+ percentBadImagery,
+ className,
+ small = true,
+}) {
+ return (
+ <>
+
+
+ {percentMapped} }}
+ />
+
+
+ {percentValidated} }}
+ />
+
+ {![null, undefined].includes(percentBadImagery) && (
+
+ {percentBadImagery} }}
+ />
+
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/components/projectCard/tests/dueDateBox.test.js b/frontend/src/components/projectCard/tests/dueDateBox.test.js
deleted file mode 100644
index a03be977bc..0000000000
--- a/frontend/src/components/projectCard/tests/dueDateBox.test.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import userEvent from '@testing-library/user-event';
-import { render, screen, waitFor } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { DueDateBox } from '../dueDateBox';
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-
-describe('test DueDate', () => {
- it('relative date formatting in English', () => {
- // six days of milliseconds plus a few seconds for the test
- const sixDaysOut = 6 * 86400 * 1000 + 10000 + Date.now();
- render(
-
-
- ,
- );
- expect(screen.getByText('6 days left')).toBeInTheDocument();
- expect(screen.getByRole('img')).toBeInTheDocument();
- });
-
- it('with tooltip message', async () => {
- // five days of milliseconds plus a few seconds for the test
- const fiveDaysOut = 5 * 86400 * 1000 + 10000 + Date.now();
- const user = userEvent.setup();
- const { container } = render(
-
-
- ,
- );
- expect(screen.queryByText('Tooltip works')).not.toBeInTheDocument();
- await user.hover(screen.getByText('5 days left'));
- await waitFor(() => expect(screen.getByText('Tooltip works')).toBeInTheDocument());
- expect(screen.getByText('Tooltip works')).toBeInTheDocument();
- expect(container.querySelectorAll('span')[0].className).toContain('bg-tan blue-grey');
- expect(container.querySelectorAll('span')[0].className).not.toContain('bg-red white fw6');
- });
-
- it('relative date formatting in English', () => {
- const { container } = render(
-
-
- ,
- );
- expect(container.querySelectorAll('span')[0].className).toContain('bg-red white');
- expect(container.querySelectorAll('span')[0].className).not.toContain('bg-tan blue-grey');
- expect(screen.getByText('9 minutes left')).toBeInTheDocument();
- expect(screen.getByRole('img')).toBeInTheDocument();
- });
-
- it('renders the appropriate clock timer icon for task status page', () => {
- render(
-
-
- ,
- );
- // A title tag has been added to the timer svg which we can access
- expect(
- screen.getByRole('img', {
- name: 'Timer',
- }),
- ).toBeInTheDocument();
- });
-
- it('does not render the clock timer icon for project details', () => {
- render(
-
-
- ,
- );
- expect(
- screen.queryByRole('img', {
- name: 'Timer',
- }),
- ).not.toBeInTheDocument();
- });
-
- it('should display text when no due date is specified', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('No due date specified')).toBeInTheDocument();
- });
-
- it('should display due date expiration message when due date has expired', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('Due date expired')).toBeInTheDocument();
- });
-
- it('should not display messages for pages other than the project detail page', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Due date expired')).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectCard/tests/dueDateBox.test.jsx b/frontend/src/components/projectCard/tests/dueDateBox.test.jsx
new file mode 100644
index 0000000000..591bdd1456
--- /dev/null
+++ b/frontend/src/components/projectCard/tests/dueDateBox.test.jsx
@@ -0,0 +1,107 @@
+import userEvent from '@testing-library/user-event';
+import { render, screen, waitFor } from '@testing-library/react';
+
+
+import { DueDateBox } from '../dueDateBox';
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+
+describe('test DueDate', () => {
+ it('relative date formatting in English', () => {
+ // six days of milliseconds plus a few seconds for the test
+ const sixDaysOut = 6 * 86400 * 1000 + 10000 + Date.now();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('6 days left')).toBeInTheDocument();
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ it('with tooltip message', async () => {
+ // five days of milliseconds plus a few seconds for the test
+ const fiveDaysOut = 5 * 86400 * 1000 + 10000 + Date.now();
+ const user = userEvent.setup();
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Tooltip works')).not.toBeInTheDocument();
+ await user.hover(screen.getByText('5 days left'));
+ await waitFor(() => expect(screen.getByText('Tooltip works')).toBeInTheDocument());
+ expect(screen.getByText('Tooltip works')).toBeInTheDocument();
+ expect(container.querySelectorAll('span')[0].className).toContain('bg-tan blue-grey');
+ expect(container.querySelectorAll('span')[0].className).not.toContain('bg-red white fw6');
+ });
+
+ it('relative date formatting in English', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.querySelectorAll('span')[0].className).toContain('bg-red white');
+ expect(container.querySelectorAll('span')[0].className).not.toContain('bg-tan blue-grey');
+ expect(screen.getByText('9 minutes left')).toBeInTheDocument();
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ it('renders the appropriate clock timer icon for task status page', () => {
+ render(
+
+
+ ,
+ );
+ // A title tag has been added to the timer svg which we can access
+ expect(
+ screen.getByRole('img', {
+ name: 'Timer',
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('does not render the clock timer icon for project details', () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.queryByRole('img', {
+ name: 'Timer',
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should display text when no due date is specified', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('No due date specified')).toBeInTheDocument();
+ });
+
+ it('should display due date expiration message when due date has expired', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Due date expired')).toBeInTheDocument();
+ });
+
+ it('should not display messages for pages other than the project detail page', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Due date expired')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectCard/tests/projectCard.test.js b/frontend/src/components/projectCard/tests/projectCard.test.js
deleted file mode 100644
index 03a68e3041..0000000000
--- a/frontend/src/components/projectCard/tests/projectCard.test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import { ProjectCard } from '../projectCard';
-import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { projects } from '../../../network/tests/mockData/projects';
-
-describe('Project Card', () => {
- it('should render project details on the card', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByRole('heading', { name: 'NRCS_Duduwa Mapping' })).toBeInTheDocument();
- expect(screen.getByAltText('IFRC')).toBeInTheDocument();
- expect(screen.getByText('Medium')).toBeInTheDocument();
- expect(screen.queryByText('Published')).not.toBeInTheDocument();
- expect(screen.getByText('Easy')).toBeInTheDocument();
- expect(screen.getByText('50')).toBeInTheDocument();
- expect(screen.getByText('total contributors')).toBeInTheDocument();
- expect(screen.getAllByRole('progressbar').length).toBe(2);
- expect(screen.getByText(/left/i)).toBeInTheDocument();
- });
-
- it('should render status and not priority if provided', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Draft')).toBeInTheDocument();
- expect(screen.queryByText('Medium')).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectCard/tests/projectCard.test.jsx b/frontend/src/components/projectCard/tests/projectCard.test.jsx
new file mode 100644
index 0000000000..054f02f241
--- /dev/null
+++ b/frontend/src/components/projectCard/tests/projectCard.test.jsx
@@ -0,0 +1,35 @@
+
+import { screen } from '@testing-library/react';
+
+import { ProjectCard } from '../projectCard';
+import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { projects } from '../../../network/tests/mockData/projects';
+
+describe('Project Card', () => {
+ it('should render project details on the card', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByRole('heading', { name: 'NRCS_Duduwa Mapping' })).toBeInTheDocument();
+ expect(screen.getByAltText('IFRC')).toBeInTheDocument();
+ expect(screen.getByText('Medium')).toBeInTheDocument();
+ expect(screen.queryByText('Published')).not.toBeInTheDocument();
+ expect(screen.getByText('Easy')).toBeInTheDocument();
+ expect(screen.getByText('50')).toBeInTheDocument();
+ expect(screen.getByText('total contributors')).toBeInTheDocument();
+ expect(screen.getAllByRole('progressbar').length).toBe(2);
+ expect(screen.getByText(/left/i)).toBeInTheDocument();
+ });
+
+ it('should render status and not priority if provided', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Draft')).toBeInTheDocument();
+ expect(screen.queryByText('Medium')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectCard/tests/projectProgressBar.test.js b/frontend/src/components/projectCard/tests/projectProgressBar.test.js
deleted file mode 100644
index c3c5f4ee81..0000000000
--- a/frontend/src/components/projectCard/tests/projectProgressBar.test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import ProjectProgressBar from '../projectProgressBar';
-import { createComponentWithIntl } from '../../../utils/testWithIntl';
-
-describe('test if projectProgressBar', () => {
- const element = createComponentWithIntl(
-
,
- );
- const testInstance = element.root;
- it('mapped bar has the correct width', () => {
- expect(
- testInstance.findByProps({ className: 'absolute bg-blue-grey br-pill hhalf hide-child' })
- .props.style,
- ).toEqual({ width: '40%' });
- });
- it('validated bar has the correct width', () => {
- expect(
- testInstance.findByProps({ className: 'absolute bg-red br-pill hhalf hide-child' }).props
- .style,
- ).toEqual({ width: '25%' });
- });
- it('has a div with the complete background bar', () => {
- expect(
- testInstance.findByProps({ className: 'bg-tan br-pill hhalf overflow-y-hidden' }).type,
- ).toBe('div');
- });
- it('the first div has the correct classes', () => {
- expect(testInstance.findAllByType('div')[0].props.className).toBe('cf db pb2');
- });
- it('tooltip is not present because it is not hovered', () => {
- expect(() =>
- testInstance.findByProps({
- className: 'db absolute top-1 z-1 dib bg-blue-dark ba br2 b--blue-dark pa2 shadow-5',
- }),
- ).toThrow(
- new Error(
- 'No instances found with props: {"className":"db absolute top-1 z-1 dib bg-blue-dark ba br2 b--blue-dark pa2 shadow-5"}',
- ),
- );
- expect(testInstance.findByProps({ className: 'relative' }).type).toBe('div');
- });
-});
-
-describe('test if projectProgressBar with value higher than 100%', () => {
- const element = createComponentWithIntl(
-
,
- );
- const testInstance = element.root;
- it('to mapped returns 100% width', () => {
- expect(
- testInstance.findByProps({ className: 'absolute bg-blue-grey br-pill hhalf hide-child' })
- .props.style,
- ).toEqual({ width: '100%' });
- });
- it('to validated returns 100% width', () => {
- expect(
- testInstance.findByProps({ className: 'absolute bg-red br-pill hhalf hide-child' }).props
- .style,
- ).toEqual({ width: '100%' });
- });
-});
diff --git a/frontend/src/components/projectCard/tests/projectProgressBar.test.tsx b/frontend/src/components/projectCard/tests/projectProgressBar.test.tsx
new file mode 100644
index 0000000000..07fcf20e0b
--- /dev/null
+++ b/frontend/src/components/projectCard/tests/projectProgressBar.test.tsx
@@ -0,0 +1,74 @@
+import ProjectProgressBar from '../projectProgressBar';
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { render } from '@testing-library/react';
+
+describe('test if projectProgressBar', () => {
+ const setup = () =>
+ render(
+
+
+ ,
+ );
+ it('mapped bar has the correct width', async () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.absolute.bg-blue-grey.br-pill.hhalf.hide-child',
+ ) as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ expect(element.style.width).toBe('40%');
+ });
+ it('validated bar has the correct width', () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.absolute.bg-red.br-pill.hhalf.hide-child',
+ ) as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ expect(element.style.width).toBe('25%');
+ });
+ it('has a div with the complete background bar', () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.bg-tan.br-pill.hhalf.overflow-y-hidden',
+ ) as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ });
+ it('the first div has the correct classes', () => {
+ const { container } = setup();
+ const element = container.querySelector('.cf.db.pb2') as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ expect(element.className).toBe('cf db pb2');
+ });
+ it('tooltip is not present because it is not hovered', async () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.db.absolute.top-1.z-1.dib.bg-blue-dark.ba.br2.b--blue-dark.pa2.shadow-5',
+ ) as HTMLDivElement;
+ expect(element).not.toBeInTheDocument();
+ expect(container.getElementsByTagName('span').length).toBe(0);
+ });
+});
+
+describe('test if projectProgressBar with value higher than 100%', () => {
+ const setup = () =>
+ render(
+
+ ,
+ ,
+ );
+ it('to mapped returns 100% width', () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.absolute.bg-blue-grey.br-pill.hhalf.hide-child',
+ ) as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ expect(element.style.width).toBe('100%');
+ });
+ it('to validated returns 100% width', () => {
+ const { container } = setup();
+ const element = container.querySelector(
+ '.absolute.bg-red.br-pill.hhalf.hide-child',
+ ) as HTMLDivElement;
+ expect(element).toBeInTheDocument();
+ expect(element.style.width).toBe('100%');
+ });
+});
diff --git a/frontend/src/components/projectCreate/fileUploadErrors.js b/frontend/src/components/projectCreate/fileUploadErrors.jsx
similarity index 100%
rename from frontend/src/components/projectCreate/fileUploadErrors.js
rename to frontend/src/components/projectCreate/fileUploadErrors.jsx
diff --git a/frontend/src/components/projectCreate/index.js b/frontend/src/components/projectCreate/index.js
deleted file mode 100644
index 1056ca3e47..0000000000
--- a/frontend/src/components/projectCreate/index.js
+++ /dev/null
@@ -1,359 +0,0 @@
-import { lazy, useState, useLayoutEffect, useCallback, Suspense, useEffect } from 'react';
-import { useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
-import { useQueryParam, NumberParam } from 'use-query-params';
-import { FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
-import ReactPlaceholder from 'react-placeholder';
-import { supported } from 'mapbox-gl';
-import area from '@turf/area';
-import bbox from '@turf/bbox';
-import { featureCollection } from '@turf/helpers';
-import truncate from '@turf/truncate';
-import toast from 'react-hot-toast';
-import MapboxDraw from '@mapbox/mapbox-gl-draw';
-import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
-
-import messages from './messages';
-import viewsMessages from '../../views/messages';
-import { createProject } from '../../store/actions/project';
-import { store } from '../../store';
-import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
-import SetAOI from './setAOI';
-import SetTaskSizes from './setTaskSizes';
-import TrimProject from './trimProject';
-import NavButtons from './navButtons';
-import Review from './review';
-import { Alert } from '../alert';
-import { makeGrid } from '../../utils/taskGrid';
-import { MAX_AOI_AREA } from '../../config';
-import {
- verifyGeometry,
- readGeoFile,
- verifyFileFormat,
- verifyFileSize,
-} from '../../utils/geoFileFunctions';
-import { getErrorMsg } from './fileUploadErrors';
-
-const ProjectCreationMap = lazy(() =>
- import('./projectCreationMap' /* webpackChunkName: "projectCreationMap" */),
-);
-
-const ProjectCreate = () => {
- const intl = useIntl();
- const token = useSelector((state) => state.auth.token);
- const navigate = useNavigate();
- const [drawModeIsActive, setDrawModeIsActive] = useState(false);
- const [showProjectsAOILayer, setShowProjectsAOILayer] = useState(false);
-
- const setDataGeom = (geom, display) => {
- const supportedGeoms = ['Polygon', 'MultiPolygon', 'LineString'];
-
- try {
- let validGeometry = verifyGeometry(geom, supportedGeoms);
-
- mapObj.map.fitBounds(bbox(validGeometry), { padding: 200 });
- const zoomLevel = 11;
- const grid = makeGrid(validGeometry, zoomLevel);
- updateMetadata({
- ...metadata,
- geom: validGeometry,
- area: (area(validGeometry) / 1e6).toFixed(2),
- zoomLevel: zoomLevel,
- taskGrid: grid,
- tempTaskGrid: grid,
- });
-
- if (display === true) {
- mapObj.map.getSource('aoi').setData(validGeometry);
- }
- } catch (err) {
- setErr({ error: true, message: getErrorMsg(err.message) || err.message });
- }
- };
-
- const uploadFile = (files) => {
- const file = files[0];
- if (!file) return null;
- try {
- setErr({ code: 403, message: null }); //reset error on new file upload
-
- verifyFileFormat(file);
- verifyFileSize(file);
-
- readGeoFile(file)
- .then((geometry) => {
- setDataGeom(geometry, true);
- })
- .catch((error) =>
- setErr({ error: true, message: getErrorMsg(error.message) || error.message }),
- );
- } catch (e) {
- deleteHandler();
- setErr({ error: true, message: getErrorMsg(e.message) || e.message });
- }
- };
-
- const deleteHandler = () => {
- const features = mapObj.draw.getAll();
- if (features.features.length > 0) {
- const id = features.features[0].id;
- mapObj.draw.delete(id);
- }
-
- if (mapObj.map.getSource('aoi')) {
- mapObj.map.getSource('aoi').setData(featureCollection([]));
- }
- updateMetadata({ ...metadata, area: 0, geom: null, arbitraryTasks: false, tasksNumber: 0 });
- };
-
- const drawHandler = () => {
- if (drawModeIsActive) {
- setDrawModeIsActive(false);
- mapObj.draw.changeMode('simple_select');
- return;
- }
- setDrawModeIsActive(true);
- const updateArea = (event) => {
- const features = mapObj.draw.getAll();
- if (features.features.length > 1) {
- const id = features.features[0].id;
- mapObj.draw.delete(id);
- }
-
- // Validate area first.
- setDataGeom(featureCollection(event.features), false);
- setDrawModeIsActive(false);
- };
-
- mapObj.map.on('draw.update', updateArea);
- mapObj.map.once('draw.create', updateArea);
- mapObj.draw.changeMode('draw_polygon');
- };
- // eslint-disable-next-line
- const [cloneFromId, setCloneFromId] = useQueryParam('cloneFrom', NumberParam);
- const [step, setStep] = useState(1);
- const [cloneProjectName, setCloneProjectName] = useState(null);
- const [cloneProjectOrg, setCloneProjectOrg] = useState(null);
- const [err, setErr] = useState({ error: false, message: null });
-
- const fetchCloneProjectInfo = useCallback(
- async (cloneFromId) => {
- const res = await fetchLocalJSONAPI(`projects/${cloneFromId}/`, token);
- setCloneProjectName(res.projectInfo.name);
- setCloneProjectOrg(res.organisation);
- },
- [setCloneProjectName, setCloneProjectOrg, token],
- );
-
- useLayoutEffect(() => {
- if (cloneFromId && !isNaN(Number(cloneFromId))) {
- fetchCloneProjectInfo(cloneFromId);
- }
- }, [cloneFromId, fetchCloneProjectInfo]);
-
- let cloneProjectData = {
- id: cloneFromId,
- name: cloneProjectName,
- organisation: cloneProjectOrg,
- };
-
- // Project information.
- const [metadata, updateMetadata] = useState({
- geom: null,
- area: 0,
- tasksNumber: 0,
- taskGrid: null,
- projectName: '',
- zoomLevel: 9,
- tempTaskGrid: null,
- arbitraryTasks: false,
- organisation: '',
- });
-
- useLayoutEffect(() => {
- let err = { error: false, message: null };
- if (metadata.area > MAX_AOI_AREA) {
- err = {
- error: true,
- message:
,
- };
- }
- setErr(err);
- }, [metadata]);
-
- const drawOptions = {
- displayControlsDefault: false,
- };
- const [mapObj, setMapObj] = useState({
- map: null,
- draw: new MapboxDraw(drawOptions),
- });
-
- const handleCreate = useCallback(
- (cloneProjectData) => {
- if (!cloneProjectData.name) {
- if (!metadata.projectName.trim()) {
- setErr({ error: true, message: intl.formatMessage(messages.noProjectName) });
- throw new Error('Missing project name.');
- }
- if (!/^[a-zA-Z]/.test(metadata.projectName)) {
- setErr({ error: true, message: intl.formatMessage(messages.projectNameValidationError) });
- throw new Error('Project name validation error.');
- }
- }
- if (!metadata.geom) {
- setErr({ error: true, message: intl.formatMessage(messages.noGeometry) });
- throw new Error('Missing geom.');
- }
- if (!metadata.organisation && !cloneProjectData.organisation) {
- setErr({ error: true, message: intl.formatMessage(messages.noOrganization) });
- throw new Error('Missing organization information.');
- }
-
- store.dispatch(createProject(metadata));
- let projectParams = {
- areaOfInterest: truncate(metadata.geom, { precision: 6 }),
- projectName: metadata.projectName,
- organisation: metadata.organisation || cloneProjectData.organisation,
- tasks: truncate(metadata.taskGrid, { precision: 6 }),
- arbitraryTasks: metadata.arbitraryTasks,
- };
-
- if (cloneProjectData.name !== null) {
- projectParams.projectName = '';
- projectParams.cloneFromProjectId = cloneProjectData.id;
- }
- pushToLocalJSONAPI('projects/', JSON.stringify(projectParams), token)
- .then((res) => {
- toast.success(
-
,
- );
- navigate(`/manage/projects/${res.projectId}`);
- })
- .catch((e) => {
- setErr({
- error: true,
- message:
,
- });
- });
- },
- [metadata, token, intl, navigate],
- );
-
- useEffect(() => {
- if (!token) {
- return navigate('/login');
- }
- }, [navigate, token]);
-
- const renderCurrentStep = () => {
- switch (step) {
- case 1:
- return (
-
- );
- case 2:
- return
;
- case 3:
- return
;
- case 4:
- return (
-
- );
- default:
- return;
- }
- };
-
- return (
-
-
-
-
-
-
-
-
}>
-
-
- {supported() && (
- <>
-
- {cloneFromId && (
-
-
-
- )}
-
{renderCurrentStep()}
- {err.error === true &&
{err.message} }
-
handleCreate(cloneProjectData)}
- />
-
-
-
MAX_AOI_AREA || metadata.area === 0 ? 'bg-red' : 'bg-green'
- }`}
- >
- ,
- sq: 2 ,
- }}
- />
-
-
- }}
- />
-
-
- >
- )}
-
-
- );
-};
-
-export default ProjectCreate;
diff --git a/frontend/src/components/projectCreate/index.jsx b/frontend/src/components/projectCreate/index.jsx
new file mode 100644
index 0000000000..19f991ee81
--- /dev/null
+++ b/frontend/src/components/projectCreate/index.jsx
@@ -0,0 +1,359 @@
+import { lazy, useState, useLayoutEffect, useCallback, Suspense, useEffect } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import { useNavigate } from 'react-router-dom';
+import { useQueryParam, NumberParam } from 'use-query-params';
+import { FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
+import ReactPlaceholder from 'react-placeholder';
+import { supported } from 'mapbox-gl';
+import area from '@turf/area';
+import bbox from '@turf/bbox';
+import { featureCollection } from '@turf/helpers';
+import truncate from '@turf/truncate';
+import toast from 'react-hot-toast';
+import MapboxDraw from '@mapbox/mapbox-gl-draw';
+import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
+
+import messages from './messages';
+import viewsMessages from '../../views/messages';
+import { createProject } from '../../store/actions/project';
+import { store } from '../../store';
+import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
+import SetAOI from './setAOI';
+import SetTaskSizes from './setTaskSizes';
+import TrimProject from './trimProject';
+import NavButtons from './navButtons';
+import Review from './review';
+import { Alert } from '../alert';
+import { makeGrid } from '../../utils/taskGrid';
+import { MAX_AOI_AREA } from '../../config';
+import {
+ verifyGeometry,
+ readGeoFile,
+ verifyFileFormat,
+ verifyFileSize,
+} from '../../utils/geoFileFunctions';
+import { getErrorMsg } from './fileUploadErrors';
+
+const ProjectCreationMap = lazy(() =>
+ import('./projectCreationMap' /* webpackChunkName: "projectCreationMap" */),
+);
+
+const ProjectCreate = () => {
+ const intl = useIntl();
+ const token = useTypedSelector((state) => state.auth.token);
+ const navigate = useNavigate();
+ const [drawModeIsActive, setDrawModeIsActive] = useState(false);
+ const [showProjectsAOILayer, setShowProjectsAOILayer] = useState(false);
+
+ const setDataGeom = (geom, display) => {
+ const supportedGeoms = ['Polygon', 'MultiPolygon', 'LineString'];
+
+ try {
+ let validGeometry = verifyGeometry(geom, supportedGeoms);
+
+ mapObj.map.fitBounds(bbox(validGeometry), { padding: 200 });
+ const zoomLevel = 11;
+ const grid = makeGrid(validGeometry, zoomLevel);
+ updateMetadata({
+ ...metadata,
+ geom: validGeometry,
+ area: (area(validGeometry) / 1e6).toFixed(2),
+ zoomLevel: zoomLevel,
+ taskGrid: grid,
+ tempTaskGrid: grid,
+ });
+
+ if (display === true) {
+ mapObj.map.getSource('aoi').setData(validGeometry);
+ }
+ } catch (err) {
+ setErr({ error: true, message: getErrorMsg(err.message) || err.message });
+ }
+ };
+
+ const uploadFile = (files) => {
+ const file = files[0];
+ if (!file) return null;
+ try {
+ setErr({ code: 403, message: null }); //reset error on new file upload
+
+ verifyFileFormat(file);
+ verifyFileSize(file);
+
+ readGeoFile(file)
+ .then((geometry) => {
+ setDataGeom(geometry, true);
+ })
+ .catch((error) =>
+ setErr({ error: true, message: getErrorMsg(error.message) || error.message }),
+ );
+ } catch (e) {
+ deleteHandler();
+ setErr({ error: true, message: getErrorMsg(e.message) || e.message });
+ }
+ };
+
+ const deleteHandler = () => {
+ const features = mapObj.draw.getAll();
+ if (features.features.length > 0) {
+ const id = features.features[0].id;
+ mapObj.draw.delete(id);
+ }
+
+ if (mapObj.map.getSource('aoi')) {
+ mapObj.map.getSource('aoi').setData(featureCollection([]));
+ }
+ updateMetadata({ ...metadata, area: 0, geom: null, arbitraryTasks: false, tasksNumber: 0 });
+ };
+
+ const drawHandler = () => {
+ if (drawModeIsActive) {
+ setDrawModeIsActive(false);
+ mapObj.draw.changeMode('simple_select');
+ return;
+ }
+ setDrawModeIsActive(true);
+ const updateArea = (event) => {
+ const features = mapObj.draw.getAll();
+ if (features.features.length > 1) {
+ const id = features.features[0].id;
+ mapObj.draw.delete(id);
+ }
+
+ // Validate area first.
+ setDataGeom(featureCollection(event.features), false);
+ setDrawModeIsActive(false);
+ };
+
+ mapObj.map.on('draw.update', updateArea);
+ mapObj.map.once('draw.create', updateArea);
+ mapObj.draw.changeMode('draw_polygon');
+ };
+ // eslint-disable-next-line
+ const [cloneFromId, setCloneFromId] = useQueryParam('cloneFrom', NumberParam);
+ const [step, setStep] = useState(1);
+ const [cloneProjectName, setCloneProjectName] = useState(null);
+ const [cloneProjectOrg, setCloneProjectOrg] = useState(null);
+ const [err, setErr] = useState({ error: false, message: null });
+
+ const fetchCloneProjectInfo = useCallback(
+ async (cloneFromId) => {
+ const res = await fetchLocalJSONAPI(`projects/${cloneFromId}/`, token);
+ setCloneProjectName(res.projectInfo.name);
+ setCloneProjectOrg(res.organisation);
+ },
+ [setCloneProjectName, setCloneProjectOrg, token],
+ );
+
+ useLayoutEffect(() => {
+ if (cloneFromId && !isNaN(Number(cloneFromId))) {
+ fetchCloneProjectInfo(cloneFromId);
+ }
+ }, [cloneFromId, fetchCloneProjectInfo]);
+
+ let cloneProjectData = {
+ id: cloneFromId,
+ name: cloneProjectName,
+ organisation: cloneProjectOrg,
+ };
+
+ // Project information.
+ const [metadata, updateMetadata] = useState({
+ geom: null,
+ area: 0,
+ tasksNumber: 0,
+ taskGrid: null,
+ projectName: '',
+ zoomLevel: 9,
+ tempTaskGrid: null,
+ arbitraryTasks: false,
+ organisation: '',
+ });
+
+ useLayoutEffect(() => {
+ let err = { error: false, message: null };
+ if (metadata.area > MAX_AOI_AREA) {
+ err = {
+ error: true,
+ message:
,
+ };
+ }
+ setErr(err);
+ }, [metadata]);
+
+ const drawOptions = {
+ displayControlsDefault: false,
+ };
+ const [mapObj, setMapObj] = useState({
+ map: null,
+ draw: new MapboxDraw(drawOptions),
+ });
+
+ const handleCreate = useCallback(
+ (cloneProjectData) => {
+ if (!cloneProjectData.name) {
+ if (!metadata.projectName.trim()) {
+ setErr({ error: true, message: intl.formatMessage(messages.noProjectName) });
+ throw new Error('Missing project name.');
+ }
+ if (!/^[a-zA-Z]/.test(metadata.projectName)) {
+ setErr({ error: true, message: intl.formatMessage(messages.projectNameValidationError) });
+ throw new Error('Project name validation error.');
+ }
+ }
+ if (!metadata.geom) {
+ setErr({ error: true, message: intl.formatMessage(messages.noGeometry) });
+ throw new Error('Missing geom.');
+ }
+ if (!metadata.organisation && !cloneProjectData.organisation) {
+ setErr({ error: true, message: intl.formatMessage(messages.noOrganization) });
+ throw new Error('Missing organization information.');
+ }
+
+ store.dispatch(createProject(metadata));
+ let projectParams = {
+ areaOfInterest: truncate(metadata.geom, { precision: 6 }),
+ projectName: metadata.projectName,
+ organisation: metadata.organisation || cloneProjectData.organisation,
+ tasks: truncate(metadata.taskGrid, { precision: 6 }),
+ arbitraryTasks: metadata.arbitraryTasks,
+ };
+
+ if (cloneProjectData.name !== null) {
+ projectParams.projectName = '';
+ projectParams.cloneFromProjectId = cloneProjectData.id;
+ }
+ pushToLocalJSONAPI('projects/', JSON.stringify(projectParams), token)
+ .then((res) => {
+ toast.success(
+
,
+ );
+ navigate(`/manage/projects/${res.projectId}`);
+ })
+ .catch((e) => {
+ setErr({
+ error: true,
+ message:
,
+ });
+ });
+ },
+ [metadata, token, intl, navigate],
+ );
+
+ useEffect(() => {
+ if (!token) {
+ return navigate('/login');
+ }
+ }, [navigate, token]);
+
+ const renderCurrentStep = () => {
+ switch (step) {
+ case 1:
+ return (
+
+ );
+ case 2:
+ return
;
+ case 3:
+ return
;
+ case 4:
+ return (
+
+ );
+ default:
+ return;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
}>
+
+
+ {supported() && (
+ <>
+
+ {cloneFromId && (
+
+
+
+ )}
+
{renderCurrentStep()}
+ {err.error === true &&
{err.message} }
+
handleCreate(cloneProjectData)}
+ />
+
+
+
MAX_AOI_AREA || metadata.area === 0 ? 'bg-red' : 'bg-green'
+ }`}
+ >
+ ,
+ sq: 2 ,
+ }}
+ />
+
+
+ }}
+ />
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default ProjectCreate;
diff --git a/frontend/src/components/projectCreate/messages.js b/frontend/src/components/projectCreate/messages.ts
similarity index 100%
rename from frontend/src/components/projectCreate/messages.js
rename to frontend/src/components/projectCreate/messages.ts
diff --git a/frontend/src/components/projectCreate/navButtons.js b/frontend/src/components/projectCreate/navButtons.js
deleted file mode 100644
index eb80376edf..0000000000
--- a/frontend/src/components/projectCreate/navButtons.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { featureCollection } from '@turf/helpers';
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import messages from './messages';
-import { Button } from '../button';
-import { useAsync } from '../../hooks/UseAsync';
-
-const clearParamsStep = (props) => {
- switch (props.index) {
- case 2: //clear Tasks
- props.mapObj.map.getSource('grid').setData(featureCollection([]));
- props.updateMetadata({ ...props.metadata, tasksNumber: 0 });
- break;
- case 3:
- props.mapObj.map.getSource('tiny-tasks').setData(featureCollection([]));
- props.updateMetadata({
- ...props.metadata,
- taskGrid: props.metadata.tempTaskGrid,
- tasksNumber: props.metadata.tempTaskGrid.features.length,
- });
- break;
- case 4:
- props.setErr({ error: false, message: '' });
- break;
- default:
- break;
- }
-
- let prevStep = props.index - 1;
-
- // If task is arbitrary. Jump to review.
- if (props.metadata.arbitraryTasks === true) {
- props.updateMetadata({ ...props.metadata, tasksNumber: 0 });
- if (props.metadata.geom.features) {
- props.updateMetadata({ ...props.metadata, tasksNumber: props.metadata.geom.features.length });
- }
- prevStep = 1;
- }
- props.setStep(prevStep);
-};
-
-const NavButtons = (props) => {
- const intl = useIntl();
-
- const createProjectFn = () => {
- return new Promise((resolve, reject) => props.handleCreate());
- };
- const createProjectAsync = useAsync(createProjectFn);
-
- const validateStep = (props) => {
- switch (props.index) {
- case 1: // Set Project AOI.
- if (props.metadata.area >= props.maxArea) {
- const message = intl.formatMessage(messages.areaOverLimitError, { n: props.maxArea });
- return { error: true, message: message };
- } else if (props.metadata.area === 0) {
- const message = intl.formatMessage(messages.noGeometry);
- return { error: true, message: message };
- } else {
- const id = props.metadata.geom.features[0].id;
- props.mapObj.draw.delete(id);
- props.mapObj.map.getSource('aoi').setData(props.metadata.geom);
- props.updateMetadata({
- ...props.metadata,
- tasksNumber: props.metadata.arbitraryTasks
- ? props.metadata.geom.features.length
- : props.metadata.taskGrid.features.length,
- });
- // clear the otherProjects source before passing to step 2
- props.mapObj.map.getSource('otherProjects').setData(featureCollection([]));
- }
-
- break;
- case 2: // Set Task grid.
- const taskGrid = props.mapObj.map.getSource('grid')._data;
- props.updateMetadata({ ...props.metadata, taskGrid: taskGrid, tempTaskGrid: taskGrid });
- break;
- case 3: // Trim Project.
- break;
-
- default:
- return;
- }
- let nextStep = props.index + 1;
-
- // If task is arbitrary. Jump to review.
- if (props.metadata.arbitraryTasks === true) {
- nextStep = 4;
- }
- props.setStep(nextStep);
- return { error: false, message: '' };
- };
- const stepHandler = (event) => {
- const resp = validateStep(props);
- props.setErr(resp);
- };
-
- return (
-
- {props.index === 1 ? null : (
- clearParamsStep(props)} className="blue-dark bg-white mr3">
-
-
- )}
- {props.index === 4 ? (
- createProjectAsync.execute()}
- className="white bg-red"
- loading={createProjectAsync.status === 'pending'}
- >
- {props.cloneProjectData.name === null ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- );
-};
-
-export default NavButtons;
diff --git a/frontend/src/components/projectCreate/navButtons.tsx b/frontend/src/components/projectCreate/navButtons.tsx
new file mode 100644
index 0000000000..bbafe804e4
--- /dev/null
+++ b/frontend/src/components/projectCreate/navButtons.tsx
@@ -0,0 +1,155 @@
+import { featureCollection } from '@turf/helpers';
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import messages from './messages';
+import { Button } from '../button';
+import { useAsync } from '../../hooks/UseAsync';
+
+const clearParamsStep = (props: {
+ index: number;
+ metadata: {
+ area: number;
+ arbitraryTasks: boolean;
+ geom: any;
+ taskGrid: any;
+ tempTaskGrid: any;
+ tasksNumber: number;
+ };
+ maxArea: number;
+ mapObj: any;
+ setStep: (step: number) => void;
+ updateMetadata: (metadata: any) => void;
+ setErr: (err: { error: boolean; message: string }) => void;
+}) => {
+ switch (props.index) {
+ case 2: //clear Tasks
+ props.mapObj.map.getSource('grid').setData(featureCollection([]));
+ props.updateMetadata({ ...props.metadata, tasksNumber: 0 });
+ break;
+ case 3:
+ props.mapObj.map.getSource('tiny-tasks').setData(featureCollection([]));
+ props.updateMetadata({
+ ...props.metadata,
+ taskGrid: props.metadata.tempTaskGrid,
+ tasksNumber: props.metadata.tempTaskGrid.features.length,
+ });
+ break;
+ case 4:
+ props.setErr({ error: false, message: '' });
+ break;
+ default:
+ break;
+ }
+
+ let prevStep = props.index - 1;
+
+ // If task is arbitrary. Jump to review.
+ if (props.metadata.arbitraryTasks === true) {
+ props.updateMetadata({ ...props.metadata, tasksNumber: 0 });
+ if (props.metadata.geom.features) {
+ props.updateMetadata({ ...props.metadata, tasksNumber: props.metadata.geom.features.length });
+ }
+ prevStep = 1;
+ }
+ props.setStep(prevStep);
+};
+
+const NavButtons = (props: any) => {
+ const intl = useIntl();
+
+ const createProjectFn = () => {
+ return new Promise(() => props.handleCreate());
+ };
+ const createProjectAsync = useAsync(createProjectFn);
+
+ const validateStep = (props: {
+ index: number;
+ metadata: {
+ area: number;
+ arbitraryTasks: boolean;
+ geom: any;
+ taskGrid: any;
+ tempTaskGrid: any;
+ tasksNumber: number;
+ };
+ maxArea: number;
+ mapObj: any;
+ setStep: (step: number) => void;
+ updateMetadata: (metadata: any) => void;
+ }) => {
+ switch (props.index) {
+ case 1: // Set Project AOI.
+ if (props.metadata.area >= props.maxArea) {
+ const message = intl.formatMessage(messages.areaOverLimitError, { n: props.maxArea });
+ return { error: true, message: message };
+ } else if (props.metadata.area === 0) {
+ const message = intl.formatMessage(messages.noGeometry);
+ return { error: true, message: message };
+ } else {
+ const id = props.metadata.geom.features[0].id;
+ props.mapObj.draw.delete(id);
+ props.mapObj.map.getSource('aoi').setData(props.metadata.geom);
+ props.updateMetadata({
+ ...props.metadata,
+ tasksNumber: props.metadata.arbitraryTasks
+ ? props.metadata.geom.features.length
+ : props.metadata.taskGrid.features.length,
+ });
+ // clear the otherProjects source before passing to step 2
+ props.mapObj.map.getSource('otherProjects').setData(featureCollection([]));
+ }
+
+ break;
+ case 2: // Set Task grid.
+ const taskGrid = props.mapObj.map.getSource('grid')._data;
+ props.updateMetadata({ ...props.metadata, taskGrid: taskGrid, tempTaskGrid: taskGrid });
+ break;
+ case 3: // Trim Project.
+ break;
+
+ default:
+ return;
+ }
+ let nextStep = props.index + 1;
+
+ // If task is arbitrary. Jump to review.
+ if (props.metadata.arbitraryTasks === true) {
+ nextStep = 4;
+ }
+ props.setStep(nextStep);
+ return { error: false, message: '' };
+ };
+ const stepHandler = () => {
+ const resp = validateStep(props);
+ props.setErr(resp);
+ };
+
+ return (
+
+ {props.index === 1 ? null : (
+ clearParamsStep(props)} className="blue-dark bg-white mr3">
+
+
+ )}
+ {props.index === 4 ? (
+ createProjectAsync.execute()}
+ className="white bg-red"
+ loading={createProjectAsync.status === 'pending'}
+ >
+ {props.cloneProjectData.name === null ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export default NavButtons;
diff --git a/frontend/src/components/projectCreate/projectCreationMap.js b/frontend/src/components/projectCreate/projectCreationMap.js
deleted file mode 100644
index 89765e0a28..0000000000
--- a/frontend/src/components/projectCreate/projectCreationMap.js
+++ /dev/null
@@ -1,308 +0,0 @@
-import { useLayoutEffect, useEffect, useCallback, useState, createRef } from 'react';
-import { useSelector } from 'react-redux';
-import mapboxgl from 'mapbox-gl';
-import 'mapbox-gl/dist/mapbox-gl.css';
-import { featureCollection } from '@turf/helpers';
-import MapboxLanguage from '@mapbox/mapbox-gl-language';
-import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
-import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
-import { useDropzone } from 'react-dropzone';
-
-import { mapboxLayerDefn } from '../projects/projectsMap';
-import useMapboxSupportedLanguage from '../../hooks/UseMapboxSupportedLanguage';
-
-import {
- MAPBOX_TOKEN,
- MAP_STYLE,
- CHART_COLOURS,
- MAPBOX_RTL_PLUGIN_URL,
- TASK_COLOURS,
-} from '../../config';
-import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
-import { useDebouncedCallback } from '../../hooks/UseThrottle';
-import { BasemapMenu } from '../basemapMenu';
-import { ProjectsAOILayerCheckBox } from './projectsAOILayerCheckBox';
-import WebglUnsupported from '../webglUnsupported';
-
-mapboxgl.accessToken = MAPBOX_TOKEN;
-try {
- mapboxgl.setRTLTextPlugin(MAPBOX_RTL_PLUGIN_URL);
-} catch {
- console.log('RTLTextPlugin is loaded');
-}
-
-const ProjectCreationMap = ({ mapObj, setMapObj, metadata, updateMetadata, step, uploadFile }) => {
- const mapRef = createRef();
- const mapboxSupportedLanguage = useMapboxSupportedLanguage();
- const token = useSelector((state) => state.auth.token);
- const [showProjectsAOILayer, setShowProjectsAOILayer] = useState(true);
- const [aoiCanBeActivated, setAOICanBeActivated] = useState(false);
- const [existingProjectsList, setExistingProjectsList] = useState([]);
- const [isAoiLoading, setIsAoiLoading] = useState(false);
- const [debouncedGetProjectsAOI] = useDebouncedCallback(() => getProjectsAOI(), 1500);
- const { getRootProps, getInputProps } = useDropzone({
- onDrop: step === 1 ? uploadFile : () => {}, // drag&drop is activated only on the first step
- noClick: true,
- noKeyboard: true,
- });
- const minZoomLevelToAOIVisualization = 9;
-
- useEffect(() => {
- fetchLocalJSONAPI('projects/').then((res) => setExistingProjectsList(res.mapResults));
- }, []);
-
- const getProjectsAOI = () => {
- if (aoiCanBeActivated && showProjectsAOILayer && step === 1) {
- setIsAoiLoading(true);
- let bounds = mapObj.map.getBounds();
- let bbox = `${bounds._sw.lng},${bounds._sw.lat},${bounds._ne.lng},${bounds._ne.lat}`;
- fetchLocalJSONAPI(`projects/queries/bbox/?bbox=${bbox}&srid=4326`, token).then((res) => {
- mapObj.map.getSource('otherProjects').setData(res);
- setIsAoiLoading(false);
- });
- }
- };
-
- const clearProjectsAOI = useCallback(() => {
- if (mapObj && mapObj.map && mapObj.map.getSource('otherProjects')) {
- mapObj.map.getSource('otherProjects').setData(featureCollection([]));
- }
- }, [mapObj]);
-
- useEffect(() => {
- if (showProjectsAOILayer && step === 1) {
- debouncedGetProjectsAOI();
- } else {
- clearProjectsAOI();
- }
- }, [showProjectsAOILayer, debouncedGetProjectsAOI, clearProjectsAOI, step]);
-
- useLayoutEffect(() => {
- if (!mapboxgl.supported()) return;
- const map = new mapboxgl.Map({
- container: mapRef.current,
- style: MAP_STYLE,
- center: [0, 0],
- zoom: 1.3,
- attributionControl: false,
- })
- .addControl(new mapboxgl.AttributionControl({ compact: false }))
- .addControl(new MapboxLanguage({ defaultLanguage: mapboxSupportedLanguage }))
- .addControl(new mapboxgl.ScaleControl({ unit: 'metric' }));
- if (MAPBOX_TOKEN) {
- map.addControl(
- new MapboxGeocoder({
- accessToken: MAPBOX_TOKEN,
- mapboxgl: mapboxgl,
- marker: false,
- collapsed: true,
- language: mapboxSupportedLanguage,
- }),
- 'top-right',
- );
- }
-
- setMapObj({ ...mapObj, map: map });
- return () => {
- mapObj.map && mapObj.map.remove();
- };
- // eslint-disable-next-line
- }, []);
-
- const addMapLayers = (map) => {
- if (map.getSource('aoi') === undefined) {
- map.addSource('aoi', {
- type: 'geojson',
- data: featureCollection([]),
- });
- map.addLayer({
- id: 'aoi',
- type: 'fill',
- source: 'aoi',
- paint: {
- 'fill-color': CHART_COLOURS.orange,
- 'fill-outline-color': '#929db3',
- 'fill-opacity': 0.3,
- },
- });
- }
-
- if (map.getSource('grid') === undefined) {
- map.addSource('grid', {
- type: 'geojson',
- data: featureCollection([]),
- });
- map.addLayer({
- id: 'grid',
- type: 'fill',
- source: 'grid',
- paint: {
- 'fill-color': '#68707f',
- 'fill-outline-color': '#00f',
- 'fill-opacity': 0.3,
- },
- });
- }
-
- if (map.getSource('tiny-tasks') === undefined) {
- map.addSource('tiny-tasks', {
- type: 'geojson',
- data: featureCollection([]),
- });
- map.addLayer({
- id: 'tiny-tasks',
- type: 'fill',
- source: 'tiny-tasks',
- paint: {
- 'fill-color': '#f0f',
- 'fill-outline-color': '#f0f',
- 'fill-opacity': 0.3,
- },
- });
- }
- if (map.getSource('otherProjects') === undefined) {
- const colorByStatus = [
- 'match',
- ['get', 'projectStatus'],
- 'DRAFT',
- TASK_COLOURS.MAPPED,
- 'PUBLISHED',
- TASK_COLOURS.VALIDATED,
- 'ARCHIVED',
- TASK_COLOURS.BADIMAGERY,
- 'rgba(0,0,0,0)', // fallback option required by mapbox-gl
- ];
- map.addSource('otherProjects', {
- type: 'geojson',
- data: featureCollection([]),
- });
- map.addLayer({
- id: 'otherProjectsLine',
- type: 'line',
- source: 'otherProjects',
- paint: {
- 'line-color': colorByStatus,
- 'line-width': 2,
- 'line-opacity': 1,
- },
- });
- map.addLayer({
- id: 'otherProjectsFill',
- type: 'fill',
- source: 'otherProjects',
- paint: {
- 'fill-color': colorByStatus,
- 'fill-opacity': ['match', ['get', 'projectStatus'], 'PUBLISHED', 0.1, 0.3],
- },
- });
- }
- };
-
- const noop = () => {};
-
- useLayoutEffect(() => {
- /* docs: https://docs.mapbox.com/mapbox-gl-js/example/cluster/ */
- const { map } = mapObj;
-
- const someResultsReady =
- existingProjectsList &&
- existingProjectsList.features &&
- existingProjectsList.features.length > 0;
-
- const mapReadyProjectsReady =
- map !== null &&
- map.isStyleLoaded() &&
- map.getSource('projects') === undefined &&
- someResultsReady;
- const projectsReadyMapLoading =
- map !== null &&
- !map.isStyleLoaded() &&
- map.getSource('projects') === undefined &&
- someResultsReady;
-
- /* set up style/sources for the map, either immediately or on base load */
- if (mapReadyProjectsReady) {
- mapboxLayerDefn(map, existingProjectsList, noop, true);
- } else if (projectsReadyMapLoading) {
- map.on('load', () => mapboxLayerDefn(map, existingProjectsList, noop, true));
- }
-
- /* refill the source on existingProjectsList changes */
- if (map !== null && map.getSource('projects') !== undefined && someResultsReady) {
- map.getSource('projects').setData(existingProjectsList);
- }
- }, [mapObj, existingProjectsList]);
-
- useLayoutEffect(() => {
- if (mapObj.map !== null && mapboxgl.supported()) {
- mapObj.map.on('moveend', (event) => {
- debouncedGetProjectsAOI();
- });
- }
- });
-
- useLayoutEffect(() => {
- if (mapObj.map !== null && mapboxgl.supported()) {
- mapObj.map.on('load', () => {
- mapObj.map.addControl(new mapboxgl.NavigationControl());
- mapObj.map.addControl(mapObj.draw);
- addMapLayers(mapObj.map);
- });
-
- // Remove area and geometry when aoi is deleted.
- mapObj.map.on('draw.delete', (event) => {
- updateMetadata({ ...metadata, geom: null, area: 0 });
- });
- // enable disable the project AOI visualization checkbox
- mapObj.map.on('zoomend', (event) => {
- if (mapObj.map.getZoom() < minZoomLevelToAOIVisualization) {
- setAOICanBeActivated(false);
- } else {
- setAOICanBeActivated(true);
- }
- });
-
- mapObj.map.on('style.load', (event) => {
- if (!MAPBOX_TOKEN) {
- return;
- }
- addMapLayers(mapObj.map);
- const features = mapObj.draw.getAll();
- if (features.features.length === 0 && mapObj.map.getSource('aoi') !== undefined) {
- mapObj.map.getSource('aoi').setData(metadata.geom);
- }
-
- if (metadata.taskGrid && step !== 1 && mapObj.map.getSource('grid') !== undefined) {
- mapObj.map.getSource('grid').setData(metadata.taskGrid);
- } else {
- mapObj.map.getSource('grid') &&
- mapObj.map.getSource('grid').setData(featureCollection([]));
- }
- });
- }
- }, [mapObj, metadata, updateMetadata, step]);
-
- if (!mapboxgl.supported()) {
- return
;
- } else {
- return (
-
-
- {step === 1 && (
-
- )}
-
-
-
-
-
- );
- }
-};
-
-export default ProjectCreationMap;
diff --git a/frontend/src/components/projectCreate/projectCreationMap.jsx b/frontend/src/components/projectCreate/projectCreationMap.jsx
new file mode 100644
index 0000000000..cab87f5ba1
--- /dev/null
+++ b/frontend/src/components/projectCreate/projectCreationMap.jsx
@@ -0,0 +1,308 @@
+import { useLayoutEffect, useEffect, useCallback, useState, createRef } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import mapboxgl from 'mapbox-gl';
+import 'mapbox-gl/dist/mapbox-gl.css';
+import { featureCollection } from '@turf/helpers';
+import MapboxLanguage from '@mapbox/mapbox-gl-language';
+import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
+import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
+import { useDropzone } from 'react-dropzone';
+
+import { mapboxLayerDefn } from '../projects/projectsMap';
+import useMapboxSupportedLanguage from '../../hooks/UseMapboxSupportedLanguage';
+
+import {
+ MAPBOX_TOKEN,
+ MAP_STYLE,
+ CHART_COLOURS,
+ MAPBOX_RTL_PLUGIN_URL,
+ TASK_COLOURS,
+} from '../../config';
+import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
+import { useDebouncedCallback } from '../../hooks/UseThrottle';
+import { BasemapMenu } from '../basemapMenu';
+import { ProjectsAOILayerCheckBox } from './projectsAOILayerCheckBox';
+import WebglUnsupported from '../webglUnsupported';
+
+mapboxgl.accessToken = MAPBOX_TOKEN;
+try {
+ mapboxgl.setRTLTextPlugin(MAPBOX_RTL_PLUGIN_URL);
+} catch {
+ console.log('RTLTextPlugin is loaded');
+}
+
+const ProjectCreationMap = ({ mapObj, setMapObj, metadata, updateMetadata, step, uploadFile }) => {
+ const mapRef = createRef();
+ const mapboxSupportedLanguage = useMapboxSupportedLanguage();
+ const token = useTypedSelector((state) => state.auth.token);
+ const [showProjectsAOILayer, setShowProjectsAOILayer] = useState(true);
+ const [aoiCanBeActivated, setAOICanBeActivated] = useState(false);
+ const [existingProjectsList, setExistingProjectsList] = useState([]);
+ const [isAoiLoading, setIsAoiLoading] = useState(false);
+ const [debouncedGetProjectsAOI] = useDebouncedCallback(() => getProjectsAOI(), 1500);
+ const { getRootProps, getInputProps } = useDropzone({
+ onDrop: step === 1 ? uploadFile : () => {}, // drag&drop is activated only on the first step
+ noClick: true,
+ noKeyboard: true,
+ });
+ const minZoomLevelToAOIVisualization = 9;
+
+ useEffect(() => {
+ fetchLocalJSONAPI('projects/').then((res) => setExistingProjectsList(res.mapResults));
+ }, []);
+
+ const getProjectsAOI = () => {
+ if (aoiCanBeActivated && showProjectsAOILayer && step === 1) {
+ setIsAoiLoading(true);
+ let bounds = mapObj.map.getBounds();
+ let bbox = `${bounds._sw.lng},${bounds._sw.lat},${bounds._ne.lng},${bounds._ne.lat}`;
+ fetchLocalJSONAPI(`projects/queries/bbox/?bbox=${bbox}&srid=4326`, token).then((res) => {
+ mapObj.map.getSource('otherProjects').setData(res);
+ setIsAoiLoading(false);
+ });
+ }
+ };
+
+ const clearProjectsAOI = useCallback(() => {
+ if (mapObj && mapObj.map && mapObj.map.getSource('otherProjects')) {
+ mapObj.map.getSource('otherProjects').setData(featureCollection([]));
+ }
+ }, [mapObj]);
+
+ useEffect(() => {
+ if (showProjectsAOILayer && step === 1) {
+ debouncedGetProjectsAOI();
+ } else {
+ clearProjectsAOI();
+ }
+ }, [showProjectsAOILayer, debouncedGetProjectsAOI, clearProjectsAOI, step]);
+
+ useLayoutEffect(() => {
+ if (!mapboxgl.supported()) return;
+ const map = new mapboxgl.Map({
+ container: mapRef.current,
+ style: MAP_STYLE,
+ center: [0, 0],
+ zoom: 1.3,
+ attributionControl: false,
+ })
+ .addControl(new mapboxgl.AttributionControl({ compact: false }))
+ .addControl(new MapboxLanguage({ defaultLanguage: mapboxSupportedLanguage }))
+ .addControl(new mapboxgl.ScaleControl({ unit: 'metric' }));
+ if (MAPBOX_TOKEN) {
+ map.addControl(
+ new MapboxGeocoder({
+ accessToken: MAPBOX_TOKEN,
+ mapboxgl: mapboxgl,
+ marker: false,
+ collapsed: true,
+ language: mapboxSupportedLanguage,
+ }),
+ 'top-right',
+ );
+ }
+
+ setMapObj({ ...mapObj, map: map });
+ return () => {
+ mapObj.map && mapObj.map.remove();
+ };
+ // eslint-disable-next-line
+ }, []);
+
+ const addMapLayers = (map) => {
+ if (map.getSource('aoi') === undefined) {
+ map.addSource('aoi', {
+ type: 'geojson',
+ data: featureCollection([]),
+ });
+ map.addLayer({
+ id: 'aoi',
+ type: 'fill',
+ source: 'aoi',
+ paint: {
+ 'fill-color': CHART_COLOURS.orange,
+ 'fill-outline-color': '#929db3',
+ 'fill-opacity': 0.3,
+ },
+ });
+ }
+
+ if (map.getSource('grid') === undefined) {
+ map.addSource('grid', {
+ type: 'geojson',
+ data: featureCollection([]),
+ });
+ map.addLayer({
+ id: 'grid',
+ type: 'fill',
+ source: 'grid',
+ paint: {
+ 'fill-color': '#68707f',
+ 'fill-outline-color': '#00f',
+ 'fill-opacity': 0.3,
+ },
+ });
+ }
+
+ if (map.getSource('tiny-tasks') === undefined) {
+ map.addSource('tiny-tasks', {
+ type: 'geojson',
+ data: featureCollection([]),
+ });
+ map.addLayer({
+ id: 'tiny-tasks',
+ type: 'fill',
+ source: 'tiny-tasks',
+ paint: {
+ 'fill-color': '#f0f',
+ 'fill-outline-color': '#f0f',
+ 'fill-opacity': 0.3,
+ },
+ });
+ }
+ if (map.getSource('otherProjects') === undefined) {
+ const colorByStatus = [
+ 'match',
+ ['get', 'projectStatus'],
+ 'DRAFT',
+ TASK_COLOURS.MAPPED,
+ 'PUBLISHED',
+ TASK_COLOURS.VALIDATED,
+ 'ARCHIVED',
+ TASK_COLOURS.BADIMAGERY,
+ 'rgba(0,0,0,0)', // fallback option required by mapbox-gl
+ ];
+ map.addSource('otherProjects', {
+ type: 'geojson',
+ data: featureCollection([]),
+ });
+ map.addLayer({
+ id: 'otherProjectsLine',
+ type: 'line',
+ source: 'otherProjects',
+ paint: {
+ 'line-color': colorByStatus,
+ 'line-width': 2,
+ 'line-opacity': 1,
+ },
+ });
+ map.addLayer({
+ id: 'otherProjectsFill',
+ type: 'fill',
+ source: 'otherProjects',
+ paint: {
+ 'fill-color': colorByStatus,
+ 'fill-opacity': ['match', ['get', 'projectStatus'], 'PUBLISHED', 0.1, 0.3],
+ },
+ });
+ }
+ };
+
+ const noop = () => {};
+
+ useLayoutEffect(() => {
+ /* docs: https://docs.mapbox.com/mapbox-gl-js/example/cluster/ */
+ const { map } = mapObj;
+
+ const someResultsReady =
+ existingProjectsList &&
+ existingProjectsList.features &&
+ existingProjectsList.features.length > 0;
+
+ const mapReadyProjectsReady =
+ map !== null &&
+ map.isStyleLoaded() &&
+ map.getSource('projects') === undefined &&
+ someResultsReady;
+ const projectsReadyMapLoading =
+ map !== null &&
+ !map.isStyleLoaded() &&
+ map.getSource('projects') === undefined &&
+ someResultsReady;
+
+ /* set up style/sources for the map, either immediately or on base load */
+ if (mapReadyProjectsReady) {
+ mapboxLayerDefn(map, existingProjectsList, noop, true);
+ } else if (projectsReadyMapLoading) {
+ map.on('load', () => mapboxLayerDefn(map, existingProjectsList, noop, true));
+ }
+
+ /* refill the source on existingProjectsList changes */
+ if (map !== null && map.getSource('projects') !== undefined && someResultsReady) {
+ map.getSource('projects').setData(existingProjectsList);
+ }
+ }, [mapObj, existingProjectsList]);
+
+ useLayoutEffect(() => {
+ if (mapObj.map !== null && mapboxgl.supported()) {
+ mapObj.map.on('moveend', () => {
+ debouncedGetProjectsAOI();
+ });
+ }
+ });
+
+ useLayoutEffect(() => {
+ if (mapObj.map !== null && mapboxgl.supported()) {
+ mapObj.map.on('load', () => {
+ mapObj.map.addControl(new mapboxgl.NavigationControl());
+ mapObj.map.addControl(mapObj.draw);
+ addMapLayers(mapObj.map);
+ });
+
+ // Remove area and geometry when aoi is deleted.
+ mapObj.map.on('draw.delete', () => {
+ updateMetadata({ ...metadata, geom: null, area: 0 });
+ });
+ // enable disable the project AOI visualization checkbox
+ mapObj.map.on('zoomend', () => {
+ if (mapObj.map.getZoom() < minZoomLevelToAOIVisualization) {
+ setAOICanBeActivated(false);
+ } else {
+ setAOICanBeActivated(true);
+ }
+ });
+
+ mapObj.map.on('style.load', () => {
+ if (!MAPBOX_TOKEN) {
+ return;
+ }
+ addMapLayers(mapObj.map);
+ const features = mapObj.draw.getAll();
+ if (features.features.length === 0 && mapObj.map.getSource('aoi') !== undefined) {
+ mapObj.map.getSource('aoi').setData(metadata.geom);
+ }
+
+ if (metadata.taskGrid && step !== 1 && mapObj.map.getSource('grid') !== undefined) {
+ mapObj.map.getSource('grid').setData(metadata.taskGrid);
+ } else {
+ mapObj.map.getSource('grid') &&
+ mapObj.map.getSource('grid').setData(featureCollection([]));
+ }
+ });
+ }
+ }, [mapObj, metadata, updateMetadata, step]);
+
+ if (!mapboxgl.supported()) {
+ return
;
+ } else {
+ return (
+
+
+ {step === 1 && (
+
+ )}
+
+
+
+
+
+ );
+ }
+};
+
+export default ProjectCreationMap;
diff --git a/frontend/src/components/projectCreate/projectsAOILayerCheckBox.js b/frontend/src/components/projectCreate/projectsAOILayerCheckBox.jsx
similarity index 100%
rename from frontend/src/components/projectCreate/projectsAOILayerCheckBox.js
rename to frontend/src/components/projectCreate/projectsAOILayerCheckBox.jsx
diff --git a/frontend/src/components/projectCreate/review.js b/frontend/src/components/projectCreate/review.js
deleted file mode 100644
index 1bd556f1d0..0000000000
--- a/frontend/src/components/projectCreate/review.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useState } from 'react';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { Alert } from '../alert';
-
-import { OrganisationSelect } from '../formInputs';
-
-export default function Review({ metadata, updateMetadata, token, projectId, cloneProjectData }) {
- const [error, setError] = useState(null);
-
- const setProjectName = (event) => {
- event.preventDefault();
- updateMetadata({ ...metadata, projectName: event.target.value });
- };
-
- return (
- <>
-
-
-
-
-
-
-
- {cloneProjectData.name === null ? (
- <>
-
-
-
-
- >
- ) : null}
-
- {cloneProjectData.organisation === null ? (
- <>
-
-
-
-
{
- setError(null);
- updateMetadata({ ...metadata, organisation: value.organisationId || '' });
- }}
- className="z-5 w-75"
- />
- >
- ) : null}
-
- {error && (
-
-
-
- )}
- >
- );
-}
diff --git a/frontend/src/components/projectCreate/review.jsx b/frontend/src/components/projectCreate/review.jsx
new file mode 100644
index 0000000000..6d642dc148
--- /dev/null
+++ b/frontend/src/components/projectCreate/review.jsx
@@ -0,0 +1,66 @@
+import { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { Alert } from '../alert';
+
+import { OrganisationSelect } from '../formInputs';
+
+export default function Review({ metadata, updateMetadata, cloneProjectData }) {
+ const [error, setError] = useState(null);
+
+ const setProjectName = (event) => {
+ event.preventDefault();
+ updateMetadata({ ...metadata, projectName: event.target.value });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {cloneProjectData.name === null ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+ {cloneProjectData.organisation === null ? (
+ <>
+
+
+
+ {
+ setError(null);
+ updateMetadata({ ...metadata, organisation: value.organisationId || '' });
+ }}
+ className="z-5 w-75"
+ />
+ >
+ ) : null}
+
+ {error && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/projectCreate/setAOI.js b/frontend/src/components/projectCreate/setAOI.jsx
similarity index 100%
rename from frontend/src/components/projectCreate/setAOI.js
rename to frontend/src/components/projectCreate/setAOI.jsx
diff --git a/frontend/src/components/projectCreate/setTaskSizes.js b/frontend/src/components/projectCreate/setTaskSizes.js
deleted file mode 100644
index 18cbd78ff1..0000000000
--- a/frontend/src/components/projectCreate/setTaskSizes.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import { useEffect, useLayoutEffect, useState, useCallback } from 'react';
-import area from '@turf/area';
-import transformScale from '@turf/transform-scale';
-import { featureCollection } from '@turf/helpers';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { splitTaskGrid, makeGrid } from '../../utils/taskGrid';
-import { CustomButton } from '../button';
-import {
- UndoIcon,
- MappedIcon,
- CircleIcon,
- FourCellsGridIcon,
- NineCellsGridIcon,
-} from '../svgIcons';
-
-export default function SetTaskSizes({ metadata, mapObj, updateMetadata }) {
- const [splitMode, setSplitMode] = useState(null);
-
- const splitHandler = useCallback(
- (event) => {
- const taskGrid = mapObj.map.getSource('grid')._data;
-
- if (metadata.tempTaskGrid === null) {
- updateMetadata({ ...metadata, tempTaskGrid: taskGrid });
- }
- // Make the geom smaller to avoid borders.
- const geom = transformScale(event.features[0].geometry, 0.5);
- const newTaskGrid = splitTaskGrid(taskGrid, geom);
-
- updateMetadata({
- ...metadata,
- taskGrid: featureCollection(newTaskGrid),
- tasksNumber: featureCollection(newTaskGrid).features.length,
- });
- },
- [updateMetadata, metadata, mapObj.map],
- );
-
- useEffect(() => {
- if (splitMode === 'click') {
- mapObj.map.on('mouseenter', 'grid', (event) => {
- mapObj.map.getCanvas().style.cursor = 'pointer';
- });
- mapObj.map.on('mouseleave', 'grid', (event) => {
- mapObj.map.getCanvas().style.cursor = '';
- });
- mapObj.map.on('click', 'grid', splitHandler);
- } else {
- mapObj.map.on('mouseenter', 'grid', (event) => {
- mapObj.map.getCanvas().style.cursor = '';
- });
- mapObj.map.off('click', 'grid', splitHandler);
- }
- }, [mapObj, splitHandler, splitMode]);
-
- const splitDrawing = () => {
- setSplitMode('draw');
- mapObj.map.on('mouseenter', 'grid', (event) => {
- mapObj.map.getCanvas().style.cursor = 'crosshair';
- });
- mapObj.map.on('mouseleave', 'grid', (event) => {
- mapObj.map.getCanvas().style.cursor = '';
- });
- mapObj.map.once('draw.create', (event) => {
- const taskGrid = mapObj.map.getSource('grid')._data;
- if (metadata.tempTaskGrid === null) {
- updateMetadata({ ...metadata, tempTaskGrid: taskGrid });
- }
-
- const id = event.features[0].id;
- mapObj.draw.delete(id);
-
- const geom = event.features[0].geometry;
- const newTaskGrid = splitTaskGrid(taskGrid, geom);
-
- updateMetadata({
- ...metadata,
- taskGrid: featureCollection(newTaskGrid),
- tasksNumber: featureCollection(newTaskGrid).features.length,
- });
- setSplitMode(null);
- });
-
- mapObj.draw.changeMode('draw_polygon');
- };
-
- const resetGrid = () => {
- updateMetadata({ ...metadata, taskGrid: metadata.tempTaskGrid });
- };
-
- const smallerSize = useCallback(() => {
- const zoomLevel = metadata.zoomLevel + 1;
- const squareGrid = makeGrid(metadata.geom, zoomLevel);
- updateMetadata({
- ...metadata,
- zoomLevel: zoomLevel,
- tempTaskGrid: squareGrid,
- taskGrid: squareGrid,
- tasksNumber: squareGrid.features.length,
- });
- }, [metadata, updateMetadata]);
-
- const largerSize = useCallback(() => {
- const zoomLevel = metadata.zoomLevel - 1;
- const squareGrid = makeGrid(metadata.geom, zoomLevel);
- if (zoomLevel > 0) {
- updateMetadata({
- ...metadata,
- zoomLevel: zoomLevel,
- tempTaskGrid: squareGrid,
- taskGrid: squareGrid,
- tasksNumber: squareGrid.features.length,
- });
- }
- }, [metadata, updateMetadata]);
-
- useLayoutEffect(() => {
- if (mapObj.map.getSource('grid') !== undefined) {
- mapObj.map.getSource('grid').setData(metadata.taskGrid);
- } else {
- mapObj.map.addSource('grid', {
- type: 'geojson',
- data: { type: 'FeatureCollection', features: metadata.taskGrid },
- });
- }
- return () => {
- // remove the split on click function when leaving the page
- mapObj.map.off('click', 'grid', splitHandler);
- };
- }, [metadata, mapObj, smallerSize, largerSize, splitHandler]);
-
- return (
- <>
-
-
-
-
-
-
-
-
-
- }
- >
-
-
- }
- >
-
-
-
-
-
-
-
-
-
- setSplitMode(splitMode === 'click' ? null : 'click')}
- icon={ }
- >
-
-
- }
- >
-
-
- }
- >
-
-
-
-
-
- {metadata.tasksNumber || 0} }}
- />
-
-
- {metadata.taskGrid && metadata.taskGrid.features && (
- {(area(metadata.taskGrid.features[0]) / 1e6).toFixed(2) || 0}
- ),
- sq: 2 ,
- }}
- />
- )}
-
-
- >
- );
-}
diff --git a/frontend/src/components/projectCreate/setTaskSizes.jsx b/frontend/src/components/projectCreate/setTaskSizes.jsx
new file mode 100644
index 0000000000..85ca5b04f3
--- /dev/null
+++ b/frontend/src/components/projectCreate/setTaskSizes.jsx
@@ -0,0 +1,218 @@
+import { useEffect, useLayoutEffect, useState, useCallback } from 'react';
+import area from '@turf/area';
+import transformScale from '@turf/transform-scale';
+import { featureCollection } from '@turf/helpers';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { splitTaskGrid, makeGrid } from '../../utils/taskGrid';
+import { CustomButton } from '../button';
+import {
+ UndoIcon,
+ MappedIcon,
+ CircleIcon,
+ FourCellsGridIcon,
+ NineCellsGridIcon,
+} from '../svgIcons';
+
+export default function SetTaskSizes({ metadata, mapObj, updateMetadata }) {
+ const [splitMode, setSplitMode] = useState(null);
+
+ const splitHandler = useCallback(
+ (event) => {
+ // Not defined for tests
+ const taskGrid = mapObj.map?.getSource('grid')._data;
+
+ if (metadata.tempTaskGrid === null) {
+ updateMetadata({ ...metadata, tempTaskGrid: taskGrid });
+ }
+ // Make the geom smaller to avoid borders.
+ const geom = transformScale(event.features[0].geometry, 0.5);
+ const newTaskGrid = splitTaskGrid(taskGrid, geom);
+
+ updateMetadata({
+ ...metadata,
+ taskGrid: featureCollection(newTaskGrid),
+ tasksNumber: featureCollection(newTaskGrid).features.length,
+ });
+ },
+ [updateMetadata, metadata, mapObj.map],
+ );
+
+ useEffect(() => {
+ if (!mapObj.map) return;
+ if (splitMode === 'click') {
+ mapObj.map.on('mouseenter', 'grid', () => {
+ mapObj.map.getCanvas().style.cursor = 'pointer';
+ });
+ mapObj.map.on('mouseleave', 'grid', () => {
+ mapObj.map.getCanvas().style.cursor = '';
+ });
+ mapObj.map.on('click', 'grid', splitHandler);
+ } else {
+ mapObj.map.on('mouseenter', 'grid', () => {
+ mapObj.map.getCanvas().style.cursor = '';
+ });
+ mapObj.map.off('click', 'grid', splitHandler);
+ }
+ }, [mapObj, splitHandler, splitMode]);
+
+ const splitDrawing = () => {
+ setSplitMode('draw');
+ if (!mapObj.map) return;
+ mapObj.map.on('mouseenter', 'grid', () => {
+ mapObj.map.getCanvas().style.cursor = 'crosshair';
+ });
+ mapObj.map.on('mouseleave', 'grid', () => {
+ mapObj.map.getCanvas().style.cursor = '';
+ });
+ mapObj.map.once('draw.create', (event) => {
+ const taskGrid = mapObj.map.getSource('grid')._data;
+ if (metadata.tempTaskGrid === null) {
+ updateMetadata({ ...metadata, tempTaskGrid: taskGrid });
+ }
+
+ const id = event.features[0].id;
+ mapObj.draw.delete(id);
+
+ const geom = event.features[0].geometry;
+ const newTaskGrid = splitTaskGrid(taskGrid, geom);
+
+ updateMetadata({
+ ...metadata,
+ taskGrid: featureCollection(newTaskGrid),
+ tasksNumber: featureCollection(newTaskGrid).features.length,
+ });
+ setSplitMode(null);
+ });
+
+ mapObj.draw.changeMode('draw_polygon');
+ };
+
+ const resetGrid = () => {
+ updateMetadata({ ...metadata, taskGrid: metadata.tempTaskGrid });
+ };
+
+ const smallerSize = useCallback(() => {
+ const zoomLevel = metadata.zoomLevel + 1;
+ const squareGrid = makeGrid(metadata.geom, zoomLevel);
+ updateMetadata({
+ ...metadata,
+ zoomLevel: zoomLevel,
+ tempTaskGrid: squareGrid,
+ taskGrid: squareGrid,
+ tasksNumber: squareGrid.features.length,
+ });
+ }, [metadata, updateMetadata]);
+
+ const largerSize = useCallback(() => {
+ const zoomLevel = metadata.zoomLevel - 1;
+ const squareGrid = makeGrid(metadata.geom, zoomLevel);
+ if (zoomLevel > 0) {
+ updateMetadata({
+ ...metadata,
+ zoomLevel: zoomLevel,
+ tempTaskGrid: squareGrid,
+ taskGrid: squareGrid,
+ tasksNumber: squareGrid.features.length,
+ });
+ }
+ }, [metadata, updateMetadata]);
+
+ useLayoutEffect(() => {
+ if (mapObj.map?.getSource('grid') !== undefined) {
+ mapObj.map?.getSource('grid').setData(metadata.taskGrid);
+ } else {
+ mapObj.map?.addSource('grid', {
+ type: 'geojson',
+ data: { type: 'FeatureCollection', features: metadata.taskGrid },
+ });
+ }
+ return () => {
+ // remove the split on click function when leaving the page
+ mapObj.map?.off('click', 'grid', splitHandler);
+ };
+ }, [metadata, mapObj, smallerSize, largerSize, splitHandler]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ setSplitMode(splitMode === 'click' ? null : 'click')}
+ icon={ }
+ >
+
+
+ }
+ >
+
+
+ }
+ >
+
+
+
+
+
+ {metadata.tasksNumber || 0} }}
+ />
+
+
+ {metadata.taskGrid && metadata.taskGrid.features && (
+ {(area(metadata.taskGrid.features[0]) / 1e6).toFixed(2) || 0}
+ ),
+ sq: 2 ,
+ }}
+ />
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.js b/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.js
deleted file mode 100644
index ce40e232c0..0000000000
--- a/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import userEvent from '@testing-library/user-event';
-
-import { ProjectsAOILayerCheckBox } from '../projectsAOILayerCheckBox';
-import { IntlProviders } from '../../../utils/testWithIntl';
-
-describe('ProjectsAOILayerCheckBox', () => {
- const testFn = jest.fn();
- it('with disabled property', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- expect(screen.getByText('Show existing projects AoIs')).toBeInTheDocument();
- expect(screen.getByRole('checkbox').className).toContain('b--grey-light');
- expect(screen.getByRole('checkbox').className).not.toContain('b--red');
- await user.hover(screen.getByText('Show existing projects AoIs'));
- await waitFor(() =>
- expect(
- screen.getByText(
- "Zoom in to be able to activate the visualization of other projects' areas of interest.",
- ),
- ).toBeInTheDocument(),
- );
- await user.click(screen.getByRole('checkbox'));
- expect(testFn).not.toHaveBeenCalled();
- });
- it('with disabled=false property', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- expect(screen.getByText('Show existing projects AoIs')).toBeInTheDocument();
- expect(screen.getByRole('checkbox').className).not.toContain('b--grey-light');
- expect(screen.getByRole('checkbox').className).toContain('b--red');
- await user.hover(screen.getByText('Show existing projects AoIs'));
- await waitFor(() =>
- expect(
- screen.getByText("Enable the visualization of the existing projects' areas of interest."),
- ).toBeInTheDocument(),
- );
- await user.click(screen.getByRole('checkbox'));
- expect(testFn).toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.jsx b/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.jsx
new file mode 100644
index 0000000000..ff2f588f2a
--- /dev/null
+++ b/frontend/src/components/projectCreate/tests/projectsAOILayerCheckBox.test.jsx
@@ -0,0 +1,50 @@
+import { render, screen, waitFor } from '@testing-library/react';
+
+import userEvent from '@testing-library/user-event';
+
+import { ProjectsAOILayerCheckBox } from '../projectsAOILayerCheckBox';
+import { IntlProviders } from '../../../utils/testWithIntl';
+
+describe('ProjectsAOILayerCheckBox', () => {
+ const testFn = vi.fn();
+ it('with disabled property', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Show existing projects AoIs')).toBeInTheDocument();
+ expect(screen.getByRole('checkbox').className).toContain('b--grey-light');
+ expect(screen.getByRole('checkbox').className).not.toContain('b--red');
+ await user.hover(screen.getByText('Show existing projects AoIs'));
+ await waitFor(() =>
+ expect(
+ screen.getByText(
+ "Zoom in to be able to activate the visualization of other projects' areas of interest.",
+ ),
+ ).toBeInTheDocument(),
+ );
+ await user.click(screen.getByRole('checkbox'));
+ expect(testFn).not.toHaveBeenCalled();
+ });
+ it('with disabled=false property', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Show existing projects AoIs')).toBeInTheDocument();
+ expect(screen.getByRole('checkbox').className).not.toContain('b--grey-light');
+ expect(screen.getByRole('checkbox').className).toContain('b--red');
+ await user.hover(screen.getByText('Show existing projects AoIs'));
+ await waitFor(() =>
+ expect(
+ screen.getByText("Enable the visualization of the existing projects' areas of interest."),
+ ).toBeInTheDocument(),
+ );
+ await user.click(screen.getByRole('checkbox'));
+ expect(testFn).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/projectCreate/tests/setTaskSizes.test.js b/frontend/src/components/projectCreate/tests/setTaskSizes.test.js
deleted file mode 100644
index c8373ce3b7..0000000000
--- a/frontend/src/components/projectCreate/tests/setTaskSizes.test.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import mapboxgl from 'mapbox-gl';
-
-import SetTaskSizes from '../setTaskSizes';
-import { projectMetadata } from '../../../utils/tests/snippets/projectMetadata';
-import { IntlProviders } from '../../../utils/testWithIntl';
-
-jest.mock('mapbox-gl/dist/mapbox-gl', () => ({
- GeolocateControl: jest.fn(),
- Map: jest.fn(() => ({
- addControl: jest.fn(),
- on: jest.fn(),
- remove: jest.fn(),
- getSource: jest.fn(),
- fitBounds: jest.fn(),
- off: jest.fn(),
- addSource: jest.fn(),
- })),
- NavigationControl: jest.fn(),
-}));
-
-const map = new mapboxgl.Map({
- container: '',
- style: {},
- center: [0, 0],
- zoom: 1.3,
- attributionControl: false,
- source: 'grid',
-});
-
-let mapObj = {
- map: map,
- draw: {},
-};
-
-describe('setTaskSizes Component', () => {
- const updateMetadata = jest.fn();
- it('renders a panel to split an AOI into a task grid', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText(/Step 2: set tasks sizes/)).toBeInTheDocument();
- expect(screen.getByText(/General task size/)).toBeInTheDocument();
- expect(screen.getByText(/Smaller/)).toBeInTheDocument();
- expect(screen.getByText(/Larger/)).toBeInTheDocument();
- expect(
- screen.getByText(
- /Make tasks smaller by clicking on specific tasks or drawing an area on the map./,
- ),
- ).toBeInTheDocument();
- expect(screen.getByText(/Click to split/)).toBeInTheDocument();
- expect(screen.getByText(/Draw area to split/)).toBeInTheDocument();
- expect(screen.getByText(/Reset/)).toBeInTheDocument();
-
- // source: https://polvara.me/posts/five-things-you-didnt-know-about-testing-library tip-4
- // test for the text displaying the number of tasks a project is created with
- screen.getByText((content, node) => {
- const hasText = (node) => node.textContent === 'A new project will be created with 0 tasks.';
- const nodeHasText = hasText(node);
- const childrenDontHaveText = Array.from(node.children).every((child) => !hasText(child));
- return nodeHasText && childrenDontHaveText;
- });
- });
-
- // To do: simulate splitting and making the task grid smaller/bigger
-});
diff --git a/frontend/src/components/projectCreate/tests/setTaskSizes.test.jsx b/frontend/src/components/projectCreate/tests/setTaskSizes.test.jsx
new file mode 100644
index 0000000000..84bf4854f6
--- /dev/null
+++ b/frontend/src/components/projectCreate/tests/setTaskSizes.test.jsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@testing-library/react';
+
+import SetTaskSizes from '../setTaskSizes';
+import { projectMetadata } from '../../../utils/tests/snippets/projectMetadata';
+import { IntlProviders } from '../../../utils/testWithIntl';
+
+vi.mock('mapbox-gl/dist/mapbox-gl', async (importOriginal) => ({
+ ...(await importOriginal()),
+ GeolocateControl: vi.fn(),
+ Map: vi.fn(() => ({
+ addControl: vi.fn(),
+ on: vi.fn(),
+ remove: vi.fn(),
+ getSource: vi.fn(),
+ fitBounds: vi.fn(),
+ off: vi.fn(),
+ addSource: vi.fn(),
+ })),
+ NavigationControl: vi.fn(),
+}));
+
+let mapObj = {
+ map: null,
+ draw: {},
+};
+
+describe('setTaskSizes Component', () => {
+ const updateMetadata = vi.fn();
+ it('renders a panel to split an AOI into a task grid', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/Step 2: set tasks sizes/)).toBeInTheDocument();
+ expect(screen.getByText(/General task size/)).toBeInTheDocument();
+ expect(screen.getByText(/Smaller/)).toBeInTheDocument();
+ expect(screen.getByText(/Larger/)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Make tasks smaller by clicking on specific tasks or drawing an area on the map./,
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Click to split/)).toBeInTheDocument();
+ expect(screen.getByText(/Draw area to split/)).toBeInTheDocument();
+ expect(screen.getByText(/Reset/)).toBeInTheDocument();
+
+ // source: https://polvara.me/posts/five-things-you-didnt-know-about-testing-library tip-4
+ // test for the text displaying the number of tasks a project is created with
+ screen.getByText((content, node) => {
+ const hasText = (node) => node.textContent === 'A new project will be created with 0 tasks.';
+ const nodeHasText = hasText(node);
+ const childrenDontHaveText = Array.from(node.children).every((child) => !hasText(child));
+ return nodeHasText && childrenDontHaveText;
+ });
+ });
+
+ // To do: simulate splitting and making the task grid smaller/bigger
+});
diff --git a/frontend/src/components/projectCreate/trimProject.js b/frontend/src/components/projectCreate/trimProject.js
deleted file mode 100644
index fb296c84ec..0000000000
--- a/frontend/src/components/projectCreate/trimProject.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useState, useEffect } from 'react';
-import { useSelector } from 'react-redux';
-import area from '@turf/area';
-import { featureCollection } from '@turf/helpers';
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-
-import messages from './messages';
-import { CustomButton } from '../button';
-import { SwitchToggle } from '../formInputs';
-import { CutIcon, WasteIcon } from '../svgIcons';
-import { pushToLocalJSONAPI } from '../../network/genericJSONRequest';
-import { useAsync } from '../../hooks/UseAsync';
-import { Alert } from '../alert';
-
-const trimTaskGrid = (params) => {
- const { clipStatus, metadata, updateMetadata, token } = params;
- const body = JSON.stringify({
- areaOfInterest: metadata.geom,
- clipToAoi: clipStatus,
- grid: metadata.tempTaskGrid,
- });
-
- return pushToLocalJSONAPI('projects/actions/intersecting-tiles/', body, token).then((grid) => {
- updateMetadata({ ...metadata, tasksNumber: grid.features.length, taskGrid: grid });
- });
-};
-
-const removeTinyTasks = (metadata, updateMetadata) => {
- const newTaskGrid = featureCollection(
- metadata.taskGrid.features.filter((task) => area(task) >= 2000),
- );
- updateMetadata({
- ...metadata,
- tasksNumber: newTaskGrid.features.length,
- taskGrid: newTaskGrid,
- });
-};
-
-export default function TrimProject({ metadata, mapObj, updateMetadata }) {
- const token = useSelector((state) => state.auth.token);
- const [clipStatus, setClipStatus] = useState(false);
- const [tinyTasksNumber, setTinyTasksNumber] = useState(0);
-
- const trimTaskGridAsync = useAsync(trimTaskGrid);
-
- useEffect(() => {
- mapObj.map
- .getSource('grid')
- .setData(featureCollection(metadata.taskGrid.features.filter((task) => area(task) >= 2000)));
- const tinyTasks = metadata.taskGrid.features.filter((task) => area(task) < 2000);
- mapObj.map.getSource('tiny-tasks').setData(featureCollection(tinyTasks));
- setTinyTasksNumber(tinyTasks.length);
- }, [metadata, mapObj]);
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- {tinyTasksNumber === 0 ? (
- <>
-
setClipStatus(!clipStatus)}
- label={ }
- />
-
-
- trimTaskGridAsync.execute({ clipStatus, metadata, updateMetadata, token })
- }
- className="bg-white blue-dark ba b--grey-light ph3 pv2"
- loading={trimTaskGridAsync.status === 'pending'}
- icon={ }
- >
-
-
-
- {trimTaskGridAsync.status === 'error' && (
-
-
- {`${trimTaskGridAsync.error.message}Error` in messages && (
-
- )}
- {!(`${trimTaskGridAsync.error.message}Error` in messages) && (
-
- )}
-
-
- )}
- >
- ) : (
-
-
-
removeTinyTasks(metadata, updateMetadata)}
- className="bg-white blue-dark ba b--grey-light ph3 pv2"
- icon={ }
- >
-
-
-
- )}
-
- >
- );
-}
diff --git a/frontend/src/components/projectCreate/trimProject.jsx b/frontend/src/components/projectCreate/trimProject.jsx
new file mode 100644
index 0000000000..16bba7b47f
--- /dev/null
+++ b/frontend/src/components/projectCreate/trimProject.jsx
@@ -0,0 +1,125 @@
+import { useState, useEffect } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import area from '@turf/area';
+import { featureCollection } from '@turf/helpers';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+
+import messages from './messages';
+import { CustomButton } from '../button';
+import { SwitchToggle } from '../formInputs';
+import { CutIcon, WasteIcon } from '../svgIcons';
+import { pushToLocalJSONAPI } from '../../network/genericJSONRequest';
+import { useAsync } from '../../hooks/UseAsync';
+import { Alert } from '../alert';
+
+const trimTaskGrid = (params) => {
+ const { clipStatus, metadata, updateMetadata, token } = params;
+ const body = JSON.stringify({
+ areaOfInterest: metadata.geom,
+ clipToAoi: clipStatus,
+ grid: metadata.tempTaskGrid,
+ });
+
+ return pushToLocalJSONAPI('projects/actions/intersecting-tiles/', body, token).then((grid) => {
+ updateMetadata({ ...metadata, tasksNumber: grid.features.length, taskGrid: grid });
+ });
+};
+
+const removeTinyTasks = (metadata, updateMetadata) => {
+ const newTaskGrid = featureCollection(
+ metadata.taskGrid.features.filter((task) => area(task) >= 2000),
+ );
+ updateMetadata({
+ ...metadata,
+ tasksNumber: newTaskGrid.features.length,
+ taskGrid: newTaskGrid,
+ });
+};
+
+export default function TrimProject({ metadata, mapObj, updateMetadata }) {
+ const token = useTypedSelector((state) => state.auth.token);
+ const [clipStatus, setClipStatus] = useState(false);
+ const [tinyTasksNumber, setTinyTasksNumber] = useState(0);
+
+ const trimTaskGridAsync = useAsync(trimTaskGrid);
+
+ useEffect(() => {
+ mapObj.map
+ .getSource('grid')
+ .setData(featureCollection(metadata.taskGrid.features.filter((task) => area(task) >= 2000)));
+ const tinyTasks = metadata.taskGrid.features.filter((task) => area(task) < 2000);
+ mapObj.map.getSource('tiny-tasks').setData(featureCollection(tinyTasks));
+ setTinyTasksNumber(tinyTasks.length);
+ }, [metadata, mapObj]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {tinyTasksNumber === 0 ? (
+ <>
+
setClipStatus(!clipStatus)}
+ label={ }
+ />
+
+
+ trimTaskGridAsync.execute({ clipStatus, metadata, updateMetadata, token })
+ }
+ className="bg-white blue-dark ba b--grey-light ph3 pv2"
+ loading={trimTaskGridAsync.status === 'pending'}
+ icon={ }
+ >
+
+
+
+ {trimTaskGridAsync.status === 'error' && (
+
+
+ {`${trimTaskGridAsync.error.message}Error` in messages && (
+
+ )}
+ {!(`${trimTaskGridAsync.error.message}Error` in messages) && (
+
+ )}
+
+
+ )}
+ >
+ ) : (
+
+
+
removeTinyTasks(metadata, updateMetadata)}
+ className="bg-white blue-dark ba b--grey-light ph3 pv2"
+ icon={ }
+ >
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/components/projectDetail/bigProjectTeaser.js b/frontend/src/components/projectDetail/bigProjectTeaser.js
deleted file mode 100644
index 9d9a98af92..0000000000
--- a/frontend/src/components/projectDetail/bigProjectTeaser.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
-
-export function BigProjectTeaser({ lastUpdated, totalContributors, style = {} }: Object) {
- return (
-
-
- {totalContributors ? (
- {chunks} ,
- }}
- />
- ) : (
-
- )}
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/projectDetail/bigProjectTeaser.jsx b/frontend/src/components/projectDetail/bigProjectTeaser.jsx
new file mode 100644
index 0000000000..8b466a4000
--- /dev/null
+++ b/frontend/src/components/projectDetail/bigProjectTeaser.jsx
@@ -0,0 +1,29 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
+
+export function BigProjectTeaser({ lastUpdated, totalContributors, style = {} }) {
+ return (
+
+
+ {totalContributors ? (
+ {chunks} ,
+ }}
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/projectDetail/downloadButtons.js b/frontend/src/components/projectDetail/downloadButtons.js
deleted file mode 100644
index 2bf57c69bf..0000000000
--- a/frontend/src/components/projectDetail/downloadButtons.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { API_URL } from '../../config';
-import { CustomButton } from '../button';
-import { NineCellsGridIcon, MappedIcon } from '../svgIcons';
-
-export const DownloadAOIButton = ({ projectId, className }: Object) => (
-
- }>
-
-
-
-);
-
-export const DownloadTaskGridButton = ({ projectId, className }: Object) => (
-
- }>
-
-
-
-);
diff --git a/frontend/src/components/projectDetail/downloadButtons.jsx b/frontend/src/components/projectDetail/downloadButtons.jsx
new file mode 100644
index 0000000000..0adf7c93be
--- /dev/null
+++ b/frontend/src/components/projectDetail/downloadButtons.jsx
@@ -0,0 +1,28 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { API_URL } from '../../config';
+import { CustomButton } from '../button';
+import { NineCellsGridIcon, MappedIcon } from '../svgIcons';
+
+export const DownloadAOIButton = ({ projectId, className }) => (
+
+ }>
+
+
+
+);
+
+export const DownloadTaskGridButton = ({ projectId, className }) => (
+
+ }>
+
+
+
+);
diff --git a/frontend/src/components/projectDetail/downloadOsmData.js b/frontend/src/components/projectDetail/downloadOsmData.js
deleted file mode 100644
index b93dd1c55a..0000000000
--- a/frontend/src/components/projectDetail/downloadOsmData.js
+++ /dev/null
@@ -1,344 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import PropTypes from 'prop-types';
-import { RoadIcon, HomeIcon, WavesIcon, TaskIcon, DownloadIcon } from '../svgIcons';
-import FileFormatCard from './fileFormatCard';
-import Popup from 'reactjs-popup';
-import { EXPORT_TOOL_S3_URL } from '../../config';
-import messages from './messages';
-import { FormattedMessage } from 'react-intl';
-import formatBytes from '../../utils/formatBytes';
-import { AnimatedLoadingIcon } from '../button';
-
-export const TITLED_ICONS = [
- {
- Icon: RoadIcon,
- title: 'roads',
- value: 'ROADS',
- featuretype: [{ type: 'lines' }],
- formats: ['GeoJSON', 'shp', 'kml'],
- },
- {
- Icon: HomeIcon,
- title: 'buildings',
- value: 'BUILDINGS',
- featuretype: [{ type: 'polygons' }],
- formats: ['GeoJSON', 'shp', 'kml'],
- },
- {
- Icon: WavesIcon,
- title: 'waterways',
- value: 'WATERWAYS',
- featuretype: [{ type: 'polygons' }, { type: 'lines' }],
- formats: ['GeoJSON', 'shp', 'kml'],
- },
- {
- Icon: TaskIcon,
- title: 'landuse',
- value: 'LAND_USE',
- featuretype: [{ type: 'points' }, { type: 'polygons' }],
- formats: ['GeoJSON', 'shp', 'kml'],
- },
-];
-
-/**
- * Renders a list of download options for OSM data based on the project mapping types.
- *
- * @param {Array} projectMappingTypes - The mapping types of the project.
- * @return {JSX.Element} - The JSX element containing the download options.
- */
-
-export const DownloadOsmData = ({ projectMappingTypes, project }) => {
- const [downloadDataList, setDownloadDataList] = useState(TITLED_ICONS);
- const [showPopup, setShowPopup] = useState(false);
- const [isDownloadingState, setIsDownloadingState] = useState(null);
- const [selectedCategoryFormat, setSelectedCategoryFormat] = useState(null);
-
- const datasetConfig = {
- dataset_prefix: `hotosm_project_${project.projectId}`,
- dataset_folder: 'TM',
- dataset_title: `Tasking Manger Project ${project.projectId}`,
- };
- /**
- * Downloads an S3 file from the given URL and saves it as a file.
- *
- * @param {string} title - The title of the file.
- * @param {string} fileFormat - The format of the file.
- * @param {string} feature_type - The feature type of the ffile.
- * @return {Promise} Promise that resolves when the download is complete.
- */
- const downloadS3File = async (title, fileFormat, feature_type) => {
- // Create the base URL for the S3 file
- const downloadUrl = `${EXPORT_TOOL_S3_URL}/${datasetConfig.dataset_folder}/${
- datasetConfig.dataset_prefix
- }/${title}/${feature_type}/${
- datasetConfig.dataset_prefix
- }_${title}_${feature_type}_${fileFormat.toLowerCase()}.zip`;
-
- // Set the state to indicate that the file download is in progress
- setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: true });
-
- try {
- // Fetch the file from the S3 URL
- const responsehead = await fetch(downloadUrl, { method: 'HEAD' });
- var handle = window.open(downloadUrl);
- handle.blur();
- window.focus();
- if (window._paq) {
- // Check if Matomo tracking array (_paq) exists
- window._paq.push([
- 'trackEvent',
- 'OSMDownloads',
- 'Click',
- `${project.projectId}_${title}_${fileFormat}`,
- ]);
- }
- // Check if the request was successful
- if (responsehead.ok) {
- setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
- } else {
- setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
- // Show a popup and throw an error if the request was not successful
- setShowPopup(true);
- throw new Error(`Request failed with status: ${responsehead.status}`);
- }
- } catch (error) {
- // Show a popup and log the error if an error occurs during the download
- setShowPopup(true);
- setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
- console.error('Error:', error.message);
- }
- };
- useEffect(() => {
- const filteredMappingTypes = downloadDataList?.filter((icon) =>
- projectMappingTypes?.includes(icon.value),
- );
- setDownloadDataList(filteredMappingTypes);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [projectMappingTypes]);
-
- useEffect(() => {
- if (!selectedCategoryFormat) return;
- async function fetchData(url) {
- const response = await fetch(url, { method: 'HEAD' });
- const data = await response;
- return {
- size: data.headers.get('Content-Length'),
- lastmod: data.headers.get('Last-Modified'),
- };
- }
-
- const multipleHeadCallForFormat = async () => {
- setIsDownloadingState({
- title: selectedCategoryFormat.title,
- fileFormat: selectedCategoryFormat.format,
- isDownloading: true,
- });
-
- const filterMappingCategory = downloadDataList.find(
- (type) => type.title === selectedCategoryFormat.title,
- );
- async function fetchAndMap(urls) {
- const results = await Promise.all(
- urls.map(async (url) => {
- const data = await fetchData(url);
- return data;
- }),
- );
-
- return results;
- }
- const multipleUrl = filterMappingCategory.featuretype.map((type) => {
- return `${EXPORT_TOOL_S3_URL}/${datasetConfig.dataset_folder}/${
- datasetConfig.dataset_prefix
- }/${selectedCategoryFormat.title}/${type.type}/${datasetConfig.dataset_prefix}_${
- selectedCategoryFormat.title
- }_${type.type}_${selectedCategoryFormat.format.toLowerCase()}.zip`;
- });
- fetchAndMap(multipleUrl)
- .then((results) => {
- var mergedArray = filterMappingCategory.featuretype.map((feature, index) => ({
- ...feature,
- ...results[index],
- }));
- const mergedListData = downloadDataList.map((list) => {
- if (list.title === selectedCategoryFormat.title) {
- return {
- ...list,
- featuretype: mergedArray,
- };
- }
- return list;
- });
- setDownloadDataList(mergedListData);
- setIsDownloadingState({
- title: selectedCategoryFormat.title,
- fileFormat: selectedCategoryFormat.fileFormat,
- isDownloading: false,
- });
- })
-
- .catch((error) => {
- console.error(error);
- setIsDownloadingState({
- title: selectedCategoryFormat.title,
- fileFormat: selectedCategoryFormat.fileFormat,
- isDownloading: false,
- });
- })
- .finally(() => {
- setIsDownloadingState({
- title: selectedCategoryFormat.title,
- fileFormat: selectedCategoryFormat.fileFormat,
- isDownloading: false,
- });
- });
- };
- multipleHeadCallForFormat();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedCategoryFormat]);
-
- return (
-
-
setShowPopup(false)}>
- {(close) => (
-
-
-
-
-
-
-
-
- {
- setShowPopup(false);
- close();
- }}
- >
- Close
-
-
-
- )}
-
- {downloadDataList.map((type) => {
- const loadingState = isDownloadingState?.isDownloading;
- return (
-
-
-
-
-
-
-
- {selectedCategoryFormat &&
- selectedCategoryFormat.title === type.title &&
- type?.featuretype?.map((typ) => (
-
- downloadS3File(
- selectedCategoryFormat.title,
- selectedCategoryFormat.format,
- typ.type,
- )
- }
- onKeyUp={() =>
- downloadS3File(
- selectedCategoryFormat.title,
- selectedCategoryFormat.format,
- typ.type,
- )
- }
- style={
- loadingState || !typ.lastmod
- ? { cursor: 'not-allowed', pointerEvents: 'none', gap: '10px' }
- : { cursor: 'pointer', gap: '10px' }
- }
- className="flex flex-row items-center pointer link hover-red color-inherit categorycard"
- >
-
-
-
-
- {typ.type} {selectedCategoryFormat.format}
-
-
- {loadingState ? (
-
- ) : (
- `(${typ.size ? formatBytes(typ.size) : 'N/A'})`
- )}
-
-
-
- {`Last Generated:`}
-
- {' '}
- {loadingState ? (
-
- ) : typ.lastmod ? (
- typ.lastmod
- ) : (
- 'Download unavailable'
- )}
-
-
-
-
- ))}
-
-
-
- );
- })}
-
- );
-};
-
-DownloadOsmData.propTypes = {
- projectMappingTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
- project: PropTypes.objectOf(PropTypes.any).isRequired,
-};
diff --git a/frontend/src/components/projectDetail/downloadOsmData.jsx b/frontend/src/components/projectDetail/downloadOsmData.jsx
new file mode 100644
index 0000000000..a3f2167f67
--- /dev/null
+++ b/frontend/src/components/projectDetail/downloadOsmData.jsx
@@ -0,0 +1,344 @@
+import { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { RoadIcon, HomeIcon, WavesIcon, TaskIcon, DownloadIcon } from '../svgIcons';
+import FileFormatCard from './fileFormatCard';
+import Popup from 'reactjs-popup';
+import { EXPORT_TOOL_S3_URL } from '../../config';
+import messages from './messages';
+import { FormattedMessage } from 'react-intl';
+import formatBytes from '../../utils/formatBytes';
+import { AnimatedLoadingIcon } from '../button';
+
+export const TITLED_ICONS = [
+ {
+ Icon: RoadIcon,
+ title: 'roads',
+ value: 'ROADS',
+ featuretype: [{ type: 'lines' }],
+ formats: ['GeoJSON', 'shp', 'kml'],
+ },
+ {
+ Icon: HomeIcon,
+ title: 'buildings',
+ value: 'BUILDINGS',
+ featuretype: [{ type: 'polygons' }],
+ formats: ['GeoJSON', 'shp', 'kml'],
+ },
+ {
+ Icon: WavesIcon,
+ title: 'waterways',
+ value: 'WATERWAYS',
+ featuretype: [{ type: 'polygons' }, { type: 'lines' }],
+ formats: ['GeoJSON', 'shp', 'kml'],
+ },
+ {
+ Icon: TaskIcon,
+ title: 'landuse',
+ value: 'LAND_USE',
+ featuretype: [{ type: 'points' }, { type: 'polygons' }],
+ formats: ['GeoJSON', 'shp', 'kml'],
+ },
+];
+
+/**
+ * Renders a list of download options for OSM data based on the project mapping types.
+ *
+ * @param {Array} projectMappingTypes - The mapping types of the project.
+ * @return {JSX.Element} - The JSX element containing the download options.
+ */
+
+export const DownloadOsmData = ({ projectMappingTypes, project }) => {
+ const [downloadDataList, setDownloadDataList] = useState(TITLED_ICONS);
+ const [showPopup, setShowPopup] = useState(false);
+ const [isDownloadingState, setIsDownloadingState] = useState(null);
+ const [selectedCategoryFormat, setSelectedCategoryFormat] = useState(null);
+
+ const datasetConfig = {
+ dataset_prefix: `hotosm_project_${project.projectId}`,
+ dataset_folder: 'TM',
+ dataset_title: `Tasking Manger Project ${project.projectId}`,
+ };
+ /**
+ * Downloads an S3 file from the given URL and saves it as a file.
+ *
+ * @param {string} title - The title of the file.
+ * @param {string} fileFormat - The format of the file.
+ * @param {string} feature_type - The feature type of the ffile.
+ * @return {Promise} Promise that resolves when the download is complete.
+ */
+ const downloadS3File = async (title, fileFormat, feature_type) => {
+ // Create the base URL for the S3 file
+ const downloadUrl = `${EXPORT_TOOL_S3_URL}/${datasetConfig.dataset_folder}/${
+ datasetConfig.dataset_prefix
+ }/${title}/${feature_type}/${
+ datasetConfig.dataset_prefix
+ }_${title}_${feature_type}_${fileFormat.toLowerCase()}.zip`;
+
+ // Set the state to indicate that the file download is in progress
+ setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: true });
+
+ try {
+ // Fetch the file from the S3 URL
+ const responsehead = await fetch(downloadUrl, { method: 'HEAD' });
+ var handle = window.open(downloadUrl);
+ handle.blur();
+ window.focus();
+ if (window._paq) {
+ // Check if Matomo tracking array (_paq) exists
+ window._paq.push([
+ 'trackEvent',
+ 'OSMDownloads',
+ 'Click',
+ `${project.projectId}_${title}_${fileFormat}`,
+ ]);
+ }
+ // Check if the request was successful
+ if (responsehead.ok) {
+ setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
+ } else {
+ setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
+ // Show a popup and throw an error if the request was not successful
+ setShowPopup(true);
+ throw new Error(`Request failed with status: ${responsehead.status}`);
+ }
+ } catch (error) {
+ // Show a popup and log the error if an error occurs during the download
+ setShowPopup(true);
+ setIsDownloadingState({ title: title, fileFormat: fileFormat, isDownloading: false });
+ console.error('Error:', error.message);
+ }
+ };
+ useEffect(() => {
+ const filteredMappingTypes = downloadDataList?.filter((icon) =>
+ projectMappingTypes?.includes(icon.value),
+ );
+ setDownloadDataList(filteredMappingTypes);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [projectMappingTypes]);
+
+ useEffect(() => {
+ if (!selectedCategoryFormat) return;
+ async function fetchData(url) {
+ const response = await fetch(url, { method: 'HEAD' });
+ const data = await response;
+ return {
+ size: data.headers.get('Content-Length'),
+ lastmod: data.headers.get('Last-Modified'),
+ };
+ }
+
+ const multipleHeadCallForFormat = async () => {
+ setIsDownloadingState({
+ title: selectedCategoryFormat.title,
+ fileFormat: selectedCategoryFormat.format,
+ isDownloading: true,
+ });
+
+ const filterMappingCategory = downloadDataList.find(
+ (type) => type.title === selectedCategoryFormat.title,
+ );
+ async function fetchAndMap(urls) {
+ const results = await Promise.all(
+ urls.map(async (url) => {
+ const data = await fetchData(url);
+ return data;
+ }),
+ );
+
+ return results;
+ }
+ const multipleUrl = filterMappingCategory.featuretype.map((type) => {
+ return `${EXPORT_TOOL_S3_URL}/${datasetConfig.dataset_folder}/${
+ datasetConfig.dataset_prefix
+ }/${selectedCategoryFormat.title}/${type.type}/${datasetConfig.dataset_prefix}_${
+ selectedCategoryFormat.title
+ }_${type.type}_${selectedCategoryFormat.format.toLowerCase()}.zip`;
+ });
+ fetchAndMap(multipleUrl)
+ .then((results) => {
+ var mergedArray = filterMappingCategory.featuretype.map((feature, index) => ({
+ ...feature,
+ ...results[index],
+ }));
+ const mergedListData = downloadDataList.map((list) => {
+ if (list.title === selectedCategoryFormat.title) {
+ return {
+ ...list,
+ featuretype: mergedArray,
+ };
+ }
+ return list;
+ });
+ setDownloadDataList(mergedListData);
+ setIsDownloadingState({
+ title: selectedCategoryFormat.title,
+ fileFormat: selectedCategoryFormat.fileFormat,
+ isDownloading: false,
+ });
+ })
+
+ .catch((error) => {
+ console.error(error);
+ setIsDownloadingState({
+ title: selectedCategoryFormat.title,
+ fileFormat: selectedCategoryFormat.fileFormat,
+ isDownloading: false,
+ });
+ })
+ .finally(() => {
+ setIsDownloadingState({
+ title: selectedCategoryFormat.title,
+ fileFormat: selectedCategoryFormat.fileFormat,
+ isDownloading: false,
+ });
+ });
+ };
+ multipleHeadCallForFormat();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedCategoryFormat]);
+
+ return (
+
+
setShowPopup(false)}>
+ {(close) => (
+
+
+
+
+
+
+
+
+ {
+ setShowPopup(false);
+ close();
+ }}
+ >
+ Close
+
+
+
+ )}
+
+ {downloadDataList.map((type) => {
+ const loadingState = isDownloadingState?.isDownloading;
+ return (
+
+
+
+
+
+
+
+ {selectedCategoryFormat &&
+ selectedCategoryFormat.title === type.title &&
+ type?.featuretype?.map((typ) => (
+
+ downloadS3File(
+ selectedCategoryFormat.title,
+ selectedCategoryFormat.format,
+ typ.type,
+ )
+ }
+ onKeyUp={() =>
+ downloadS3File(
+ selectedCategoryFormat.title,
+ selectedCategoryFormat.format,
+ typ.type,
+ )
+ }
+ style={
+ loadingState || !typ.lastmod
+ ? { cursor: 'not-allowed', pointerEvents: 'none', gap: '10px' }
+ : { cursor: 'pointer', gap: '10px' }
+ }
+ className="flex flex-row items-center pointer link hover-red color-inherit categorycard"
+ >
+
+
+
+
+ {typ.type} {selectedCategoryFormat.format}
+
+
+ {loadingState ? (
+
+ ) : (
+ `(${typ.size ? formatBytes(typ.size) : 'N/A'})`
+ )}
+
+
+
+ {`Last Generated:`}
+
+ {' '}
+ {loadingState ? (
+
+ ) : typ.lastmod ? (
+ typ.lastmod
+ ) : (
+ 'Download unavailable'
+ )}
+
+
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+};
+
+DownloadOsmData.propTypes = {
+ projectMappingTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ project: PropTypes.objectOf(PropTypes.any).isRequired,
+};
diff --git a/frontend/src/components/projectDetail/favorites.js b/frontend/src/components/projectDetail/favorites.js
deleted file mode 100644
index 06a25a5b00..0000000000
--- a/frontend/src/components/projectDetail/favorites.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-
-import { FlagIcon } from '../svgIcons';
-import { useFavProjectAPI } from '../../hooks/UseFavProjectAPI';
-import messages from './messages';
-
-export const AddToFavorites = (props) => {
- const navigate = useNavigate();
- const userToken = useSelector((state) => state.auth.token);
- const [state, dispatchToggle] = useFavProjectAPI(false, props.projectId, userToken);
- const isFav = state.isFav;
- const isLoading = state.isLoading;
-
- return (
- <>
- (userToken ? dispatchToggle() : navigate('/login'))}
- className={`${
- !props.projectId ? 'dn' : ''
- } input-reset base-font bg-white blue-dark bn pointer flex nowrap items-center ml3`}
- >
-
-
- {isFav ? (
-
- ) : (
-
- )}
-
-
- >
- );
-};
diff --git a/frontend/src/components/projectDetail/favorites.jsx b/frontend/src/components/projectDetail/favorites.jsx
new file mode 100644
index 0000000000..37bcf8aa77
--- /dev/null
+++ b/frontend/src/components/projectDetail/favorites.jsx
@@ -0,0 +1,38 @@
+import { useTypedSelector } from '@Store/hooks';
+import { useNavigate } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import { FlagIcon } from '../svgIcons';
+import { useFavProjectAPI } from '../../hooks/UseFavProjectAPI';
+import messages from './messages';
+
+export const AddToFavorites = (props) => {
+ const navigate = useNavigate();
+ const userToken = useTypedSelector((state) => state.auth.token);
+ const [state, dispatchToggle] = useFavProjectAPI(false, props.projectId, userToken);
+ const isFav = state.isFav;
+ const isLoading = state.isLoading;
+
+ return (
+ <>
+ (userToken ? dispatchToggle() : navigate('/login'))}
+ className={`${
+ !props.projectId ? 'dn' : ''
+ } input-reset base-font bg-white blue-dark bn pointer flex nowrap items-center ml3`}
+ >
+
+
+ {isFav ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/projectDetail/fileFormatCard.js b/frontend/src/components/projectDetail/fileFormatCard.jsx
similarity index 100%
rename from frontend/src/components/projectDetail/fileFormatCard.js
rename to frontend/src/components/projectDetail/fileFormatCard.jsx
diff --git a/frontend/src/components/projectDetail/footer.js b/frontend/src/components/projectDetail/footer.js
deleted file mode 100644
index 1e66dbe6f6..0000000000
--- a/frontend/src/components/projectDetail/footer.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { useRef, Fragment } from 'react';
-import { Link, useLocation } from 'react-router-dom';
-import { useSelector } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-
-import { Button } from '../button';
-import messages from './messages';
-import { ShareButton } from './shareButton';
-import { AddToFavorites } from './favorites';
-import { HorizontalScroll } from '../horizontalScroll';
-
-import './styles.scss';
-import { ENABLE_EXPORT_TOOL } from '../../config';
-
-const menuItems = [
- {
- href: '#top',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#description',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#coordination',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#teams',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#questionsAndComments',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#contributions',
- label: ,
- isVisibleCondition: true,
- },
- {
- href: '#downloadOsmData',
- label: ,
- isVisibleCondition: +ENABLE_EXPORT_TOOL === 1,
- },
- {
- href: '#similarProjects',
- label: ,
- isVisibleCondition: true,
- },
-];
-
-export const ProjectDetailFooter = ({ className, projectId }) => {
- const userIsloggedIn = useSelector((state) => state.auth.token);
- const menuItemsContainerRef = useRef(null);
- const { pathname } = useLocation();
-
- return (
-
- {/* TODO ADD ANCHORS */}
-
-
- {menuItems.map((menuItem, index) => {
- if (menuItem.isVisibleCondition) {
- return (
-
-
- {menuItem.label}
-
- {index < menuItems.length - 1 && · }
-
- );
- } else {
- return null;
- }
- })}
-
-
-
-
- {userIsloggedIn &&
}
-
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectDetail/footer.jsx b/frontend/src/components/projectDetail/footer.jsx
new file mode 100644
index 0000000000..0214f5e26b
--- /dev/null
+++ b/frontend/src/components/projectDetail/footer.jsx
@@ -0,0 +1,108 @@
+import { useRef, Fragment } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { useTypedSelector } from '@Store/hooks';
+import { FormattedMessage } from 'react-intl';
+
+import { Button } from '../button';
+import messages from './messages';
+import { ShareButton } from './shareButton';
+import { AddToFavorites } from './favorites';
+import { HorizontalScroll } from '../horizontalScroll';
+
+import './styles.scss';
+import { ENABLE_EXPORT_TOOL } from '../../config';
+
+const menuItems = [
+ {
+ href: '#top',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#description',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#coordination',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#teams',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#questionsAndComments',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#contributions',
+ label: ,
+ isVisibleCondition: true,
+ },
+ {
+ href: '#downloadOsmData',
+ label: ,
+ isVisibleCondition: +ENABLE_EXPORT_TOOL === 1,
+ },
+ {
+ href: '#similarProjects',
+ label: ,
+ isVisibleCondition: true,
+ },
+];
+
+export const ProjectDetailFooter = ({ className, projectId }) => {
+ const userIsloggedIn = useTypedSelector((state) => state.auth.token);
+ const menuItemsContainerRef = useRef(null);
+ const { pathname } = useLocation();
+
+ return (
+
+ {/* TODO ADD ANCHORS */}
+
+
+ {menuItems.map((menuItem, index) => {
+ if (menuItem.isVisibleCondition) {
+ return (
+
+
+ {menuItem.label}
+
+ {index < menuItems.length - 1 && · }
+
+ );
+ } else {
+ return null;
+ }
+ })}
+
+
+
+
+ {userIsloggedIn &&
}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectDetail/header.js b/frontend/src/components/projectDetail/header.js
deleted file mode 100644
index a0b0fb29fd..0000000000
--- a/frontend/src/components/projectDetail/header.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useSelector } from 'react-redux';
-import { Link } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { PriorityBox } from '../projectCard/priorityBox';
-import { translateCountry } from '../../utils/countries';
-import { ProjectVisibilityBox } from './visibilityBox';
-import { ProjectStatusBox } from './statusBox';
-import { EditButton } from '../button';
-import { useEditProjectAllowed } from '../../hooks/UsePermissions';
-
-export function HeaderLine({ author, projectId, priority, showEditLink, organisation }: Object) {
- const projectIdLink = (
-
- #{projectId}
-
- );
- return (
-
-
- {projectIdLink}
- {organisation ? | {organisation} : null}
-
-
- {showEditLink && (
-
-
-
- )}
- {priority && (
-
- )}
-
-
- );
-}
-
-export const ProjectHeader = ({ project, showEditLink }: Object) => {
- const locale = useSelector((state) => state.preferences.locale);
- const [userCanEditProject] = useEditProjectAllowed(project);
-
- return (
- <>
-
-
-
- {project.projectInfo && project.projectInfo.name}
-
- {project.private &&
}
- {['DRAFT', 'ARCHIVED'].includes(project.status) && (
-
- )}
-
-
- >
- );
-};
-
-export function TagLine({ campaigns = [], countries = [], interests = [] }: Object) {
- const locale = useSelector((state) => state.preferences.locale);
- const formattedCampaigns = campaigns.map((campaign) => campaign.name).join(', ');
- const formattedCountries = locale.includes('en') ? countries.join(', ') : countries;
- const formattedInterests = interests.map((interest) => interest.name).join(', ');
- // Remove empty formatted strings
- const tags = [formattedCampaigns, formattedCountries, formattedInterests].filter((n) => n);
-
- return (
-
- {tags.map((tag, index) => (
-
- {index !== 0 && · }
- {tag}
-
- ))}
-
- );
-}
diff --git a/frontend/src/components/projectDetail/header.jsx b/frontend/src/components/projectDetail/header.jsx
new file mode 100644
index 0000000000..777b8849ea
--- /dev/null
+++ b/frontend/src/components/projectDetail/header.jsx
@@ -0,0 +1,95 @@
+import { useTypedSelector } from '@Store/hooks';
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { PriorityBox } from '../projectCard/priorityBox';
+import { translateCountry } from '../../utils/countries';
+import { ProjectVisibilityBox } from './visibilityBox';
+import { ProjectStatusBox } from './statusBox';
+import { EditButton } from '../button';
+import { useEditProjectAllowed } from '../../hooks/UsePermissions';
+
+export function HeaderLine({ projectId, priority, showEditLink, organisation }) {
+ const projectIdLink = (
+
+ #{projectId}
+
+ );
+ return (
+
+
+ {projectIdLink}
+ {organisation ? | {organisation} : null}
+
+
+ {showEditLink && (
+
+
+
+ )}
+ {priority && (
+
+ )}
+
+
+ );
+}
+
+export const ProjectHeader = ({ project, showEditLink }) => {
+ const locale = useTypedSelector((state) => state.preferences.locale);
+ const [userCanEditProject] = useEditProjectAllowed(project);
+
+ return (
+ <>
+
+
+
+ {project.projectInfo && project.projectInfo.name}
+
+ {project.private &&
}
+ {['DRAFT', 'ARCHIVED'].includes(project.status) && (
+
+ )}
+
+
+ >
+ );
+};
+
+export function TagLine({ campaigns = [], countries = [], interests = [] }) {
+ const locale = useTypedSelector((state) => state.preferences.locale);
+ const formattedCampaigns = campaigns.map((campaign) => campaign.name).join(', ');
+ const formattedCountries = locale.includes('en') ? countries.join(', ') : countries;
+ const formattedInterests = interests.map((interest) => interest.name).join(', ');
+ // Remove empty formatted strings
+ const tags = [formattedCampaigns, formattedCountries, formattedInterests].filter((n) => n);
+
+ return (
+
+ {tags.map((tag, index) => (
+
+ {index !== 0 && · }
+ {tag}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/projectDetail/index.js b/frontend/src/components/projectDetail/index.js
deleted file mode 100644
index 3baaa17aad..0000000000
--- a/frontend/src/components/projectDetail/index.js
+++ /dev/null
@@ -1,464 +0,0 @@
-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';
-import { supported } from 'mapbox-gl';
-import PropTypes from 'prop-types';
-
-import messages from './messages';
-import viewsMessages from '../../views/messages';
-import { UserAvatar, UserAvatarList } from '../user/avatar';
-import { TasksMap } from '../taskSelection/map.js';
-import { ProjectHeader } from './header';
-import { DownloadAOIButton, DownloadTaskGridButton } from './downloadButtons';
-import { TeamsBoxList } from '../teamsAndOrgs/teams';
-import { htmlFromMarkdown } from '../../utils/htmlFromMarkdown';
-import { ProjectDetailFooter } from './footer';
-import { QuestionsAndComments } from './questionsAndComments';
-import { SimilarProjects } from './similarProjects';
-import { PermissionBox } from './permissionBox';
-import { CustomButton } from '../button';
-import { ProjectInfoPanel } from './infoPanel';
-import { OSMChaButton } from './osmchaButton';
-import { LiveViewButton } from './liveViewButton';
-import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
-import useHasLiveMonitoringFeature from '../../hooks/UseHasLiveMonitoringFeature';
-import { useProjectContributionsQuery, useProjectTimelineQuery } from '../../api/projects';
-import { Alert } from '../alert';
-
-import './styles.scss';
-import { useWindowSize } from '../../hooks/UseWindowSize';
-import { DownloadOsmData } from './downloadOsmData.js';
-import { ENABLE_EXPORT_TOOL } from '../../config/index.js';
-
-/* lazy imports must be last import */
-const ProjectTimeline = lazy(() => import('./timeline' /* webpackChunkName: "timeline" */));
-
-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: [
- {
- type: 'Feature',
- properties: {},
- geometry: props.project.areaOfInterest,
- },
- ],
- };
-
- const centroidGeoJSON = props.project.areaOfInterest && {
- type: 'FeatureCollection',
- features: [centroid(props.project.areaOfInterest)],
- };
-
- return (
-
- {
- /* It disturbs layout otherwise */
- /* eslint-disable-next-line */
-
- }
-
- {taskBordersOnly && supported() && (
-
-
- setTaskBordersOnly(false)}
- onKeyDown={() => setTaskBordersOnly(false)}
- className="pb2 mh2 pointer ph2 "
- >
-
-
-
-
- )}
-
- );
-};
-
-export const ProjectDetailLeft = ({ project, contributors, className, type }) => {
- const htmlShortDescription =
- project.projectInfo && htmlFromMarkdown(project.projectInfo.shortDescription);
-
- return (
-
- );
-};
-
-export const ProjectDetail = (props) => {
- useSetProjectPageTitleTag(props.project);
- const size = useWindowSize();
- const { id: projectId } = useParams();
- const { data: contributors, status: contributorsStatus } = useProjectContributionsQuery(
- props.project.projectId,
- );
- const { data: timelineData, status: timelineDataStatus } = useProjectTimelineQuery(
- props.project.projectId,
- );
-
- const hasLiveMonitoringFeature = useHasLiveMonitoringFeature();
-
- const htmlDescription =
- props.project.projectInfo && htmlFromMarkdown(props.project.projectInfo.description);
- const h2Classes = 'pl4 f3 f2-ns fw5 mt2 mb3 mb4-ns ttu barlow-condensed blue-dark';
- const userLink = (
-
- {props.project.author}
-
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {props.project.organisationName && (
- <>
-
-
- {props.project.organisationName}
-
- ),
- user: userLink,
- }}
- />
-
- {props.project.organisationLogo && (
-
-
-
- )}
- >
- )}
- {!props.project.organisationName && props.project.author && (
- <>
-
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
- {props.project.teams && }
-
-
-
-
-
-
-
-
-
-
-
-
-
- {contributorsStatus === 'loading' && (
-
- )}
- {contributorsStatus === 'error' && (
-
- )}
- {contributorsStatus === 'success' && (
-
12 ? 12 : parseInt(size[0] / 75)}
- />
- )}
-
-
- {/* Download OSM Data section Start */}
- {/* Converted String to Integer */}
- {+ENABLE_EXPORT_TOOL === 1 && (
-
- )}
-
- {/* Download OSM Data section End */}
-
-
-
-
-
-
-
-
-
- {timelineDataStatus === 'loading' && (
-
- )}
- {timelineDataStatus === 'error' && (
-
-
-
- )}
- {timelineDataStatus === 'success' && (
-
Loading... }>
-
-
- )}
-
-
-
-
-
-
-
-
-
- {/* show live view button only when the project has live monitoring feature */}
- {hasLiveMonitoringFeature && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const GeometryPropType = PropTypes.shape({
- type: PropTypes.oneOf([
- 'Point',
- 'MultiPoint',
- 'LineString',
- 'MultiLineString',
- 'Polygon',
- 'MultiPolygon',
- 'GeometryCollection',
- ]),
- coordinates: PropTypes.array,
- geometries: PropTypes.array,
-});
-const FeaturePropType = PropTypes.shape({
- type: PropTypes.oneOf(['Feature']),
- geometry: GeometryPropType,
- properties: PropTypes.object,
-});
-const FeatureCollectionPropType = PropTypes.shape({
- type: PropTypes.oneOf(['FeatureCollection']),
- features: PropTypes.arrayOf(FeaturePropType).isRequired,
-});
-
-ProjectDetail.propTypes = {
- project: PropTypes.shape({
- projectId: PropTypes.number,
- projectInfo: PropTypes.shape({
- description: PropTypes.string,
- }),
- mappingTypes: PropTypes.arrayOf(PropTypes.any).isRequired,
- author: PropTypes.string,
- organisationName: PropTypes.string,
- organisationSlug: PropTypes.string,
- organisationLogo: PropTypes.string,
- mappingPermission: PropTypes.string,
- validationPermission: PropTypes.string,
- teams: PropTypes.arrayOf(PropTypes.object),
- }).isRequired,
- className: PropTypes.string,
-};
-
-ProjectDetailMap.propTypes = {
- project: PropTypes.shape({
- areaOfInterest: PropTypes.object,
- priorityAreas: PropTypes.arrayOf(PropTypes.object),
- }).isRequired,
- // Tasks are a GeoJSON FeatureCollection
- tasks: FeatureCollectionPropType,
- navigate: PropTypes.func,
- type: PropTypes.string,
- tasksError: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
- projectLoading: PropTypes.bool,
- taskBordersOnly: PropTypes.bool,
-};
-
-ProjectDetailLeft.propTypes = {
- project: PropTypes.shape({
- projectInfo: PropTypes.shape({
- shortDescription: PropTypes.string,
- }),
- projectId: PropTypes.number,
- tasks: FeatureCollectionPropType,
- }).isRequired,
- contributors: PropTypes.arrayOf(PropTypes.object),
- className: PropTypes.string,
- type: PropTypes.string,
-};
diff --git a/frontend/src/components/projectDetail/index.jsx b/frontend/src/components/projectDetail/index.jsx
new file mode 100644
index 0000000000..7befa6a565
--- /dev/null
+++ b/frontend/src/components/projectDetail/index.jsx
@@ -0,0 +1,480 @@
+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';
+import { supported } from 'mapbox-gl';
+import PropTypes from 'prop-types';
+
+import messages from './messages';
+import viewsMessages from '../../views/messages';
+import { UserAvatar, UserAvatarList } from '../user/avatar';
+import { TasksMap } from '../taskSelection/map';
+import { ProjectHeader } from './header';
+import { DownloadAOIButton, DownloadTaskGridButton } from './downloadButtons';
+import { TeamsBoxList } from '../teamsAndOrgs/teams';
+import { htmlFromMarkdown } from '../../utils/htmlFromMarkdown';
+import { ProjectDetailFooter } from './footer';
+import { QuestionsAndComments } from './questionsAndComments';
+import { SimilarProjects } from './similarProjects';
+import { PermissionBox } from './permissionBox';
+import { CustomButton } from '../button';
+import { ProjectInfoPanel } from './infoPanel';
+import { OSMChaButton } from './osmchaButton';
+import { LiveViewButton } from './liveViewButton';
+import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
+import useHasLiveMonitoringFeature from '../../hooks/UseHasLiveMonitoringFeature';
+import { useProjectContributionsQuery, useProjectTimelineQuery } from '../../api/projects';
+import { Alert } from '../alert';
+
+import './styles.scss';
+import { useWindowSize } from '../../hooks/UseWindowSize';
+import { DownloadOsmData } from './downloadOsmData';
+import { ENABLE_EXPORT_TOOL } from '../../config/index';
+
+/* lazy imports must be last import */
+const ProjectTimeline = lazy(() => import('./timeline' /* webpackChunkName: "timeline" */));
+
+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: [
+ {
+ type: 'Feature',
+ properties: {},
+ geometry: props.project.areaOfInterest,
+ },
+ ],
+ };
+
+ const centroidGeoJSON = props.project.areaOfInterest && {
+ type: 'FeatureCollection',
+ features: [centroid(props.project.areaOfInterest)],
+ };
+
+ return (
+
+ {
+ /* It disturbs layout otherwise */
+ /* eslint-disable-next-line */
+
+ }
+
+ {taskBordersOnly && supported() && (
+
+
+ setTaskBordersOnly(false)}
+ onKeyDown={() => setTaskBordersOnly(false)}
+ className="pb2 mh2 pointer ph2 "
+ >
+
+
+
+
+ )}
+
+ );
+};
+
+export const ProjectDetailLeft = ({ project, contributors, className, type }) => {
+ const [htmlShortDescriptionHTML, setHtmlShortDescriptionHTML] = useState('');
+
+ useEffect(() => {
+ if (!project.projectInfo) return;
+ (async () => {
+ setHtmlShortDescriptionHTML(await htmlFromMarkdown(project.projectInfo.shortDescription));
+ })();
+ }, [project.projectInfo, project.projectInfo?.shortDescription]);
+
+ return (
+
+ );
+};
+
+export const ProjectDetail = (props) => {
+ useSetProjectPageTitleTag(props.project);
+ const size = useWindowSize();
+ const { id: projectId } = useParams();
+ const { data: contributors, status: contributorsStatus } = useProjectContributionsQuery(
+ props.project.projectId,
+ );
+ const { data: timelineData, status: timelineDataStatus } = useProjectTimelineQuery(
+ props.project.projectId,
+ );
+ const [htmlDescriptionHTML, setHtmlDescriptionHTML] = useState('');
+
+ const hasLiveMonitoringFeature = useHasLiveMonitoringFeature();
+
+ useEffect(() => {
+ if (!props.project.projectInfo) return;
+ (async () => {
+ setHtmlDescriptionHTML(await htmlFromMarkdown(props.project.projectInfo.description));
+ })();
+ }, [props.project.projectInfo, props.project.projectInfo?.description]);
+
+ const h2Classes = 'pl4 f3 f2-ns fw5 mt2 mb3 mb4-ns ttu barlow-condensed blue-dark';
+ const userLink = (
+
+ {props.project.author}
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.project.organisationName && (
+ <>
+
+
+ {props.project.organisationName}
+
+ ),
+ user: userLink,
+ }}
+ />
+
+ {props.project.organisationLogo && (
+
+
+
+ )}
+ >
+ )}
+ {!props.project.organisationName && props.project.author && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+ {props.project.teams && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {contributorsStatus === 'pending' && (
+
+ )}
+ {contributorsStatus === 'error' && (
+
+ )}
+ {contributorsStatus === 'success' && (
+
12 ? 12 : parseInt(size[0] / 75)}
+ />
+ )}
+
+
+ {/* Download OSM Data section Start */}
+ {/* Converted String to Integer */}
+ {+ENABLE_EXPORT_TOOL === 1 && (
+
+ )}
+
+ {/* Download OSM Data section End */}
+
+
+
+
+
+
+
+
+
+ {timelineDataStatus === 'pending' && (
+
+ )}
+ {timelineDataStatus === 'error' && (
+
+
+
+ )}
+ {timelineDataStatus === 'success' && (
+
Loading... }>
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* show live view button only when the project has live monitoring feature */}
+ {hasLiveMonitoringFeature && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const GeometryPropType = PropTypes.shape({
+ type: PropTypes.oneOf([
+ 'Point',
+ 'MultiPoint',
+ 'LineString',
+ 'MultiLineString',
+ 'Polygon',
+ 'MultiPolygon',
+ 'GeometryCollection',
+ ]),
+ coordinates: PropTypes.array,
+ geometries: PropTypes.array,
+});
+const FeaturePropType = PropTypes.shape({
+ type: PropTypes.oneOf(['Feature']),
+ geometry: GeometryPropType,
+ properties: PropTypes.object,
+});
+const FeatureCollectionPropType = PropTypes.shape({
+ type: PropTypes.oneOf(['FeatureCollection']),
+ features: PropTypes.arrayOf(FeaturePropType).isRequired,
+});
+
+ProjectDetail.propTypes = {
+ project: PropTypes.shape({
+ projectId: PropTypes.number,
+ projectInfo: PropTypes.shape({
+ description: PropTypes.string,
+ }),
+ mappingTypes: PropTypes.arrayOf(PropTypes.any).isRequired,
+ author: PropTypes.string,
+ organisationName: PropTypes.string,
+ organisationSlug: PropTypes.string,
+ organisationLogo: PropTypes.string,
+ mappingPermission: PropTypes.string,
+ validationPermission: PropTypes.string,
+ teams: PropTypes.arrayOf(PropTypes.object),
+ }).isRequired,
+ className: PropTypes.string,
+};
+
+ProjectDetailMap.propTypes = {
+ project: PropTypes.shape({
+ areaOfInterest: PropTypes.object,
+ priorityAreas: PropTypes.arrayOf(PropTypes.object),
+ }).isRequired,
+ // Tasks are a GeoJSON FeatureCollection
+ tasks: FeatureCollectionPropType,
+ navigate: PropTypes.func,
+ type: PropTypes.string,
+ tasksError: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ projectLoading: PropTypes.bool,
+ taskBordersOnly: PropTypes.bool,
+};
+
+ProjectDetailLeft.propTypes = {
+ project: PropTypes.shape({
+ projectInfo: PropTypes.shape({
+ shortDescription: PropTypes.string,
+ }),
+ projectId: PropTypes.number,
+ tasks: FeatureCollectionPropType,
+ }).isRequired,
+ contributors: PropTypes.arrayOf(PropTypes.object),
+ className: PropTypes.string,
+ type: PropTypes.string,
+};
diff --git a/frontend/src/components/projectDetail/infoPanel.js b/frontend/src/components/projectDetail/infoPanel.js
deleted file mode 100644
index 8f4e2ac41e..0000000000
--- a/frontend/src/components/projectDetail/infoPanel.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import ReactPlaceholder from 'react-placeholder';
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import messages from './messages';
-import { MappingTypes } from '../mappingTypes';
-import { Imagery } from '../taskSelection/imagery';
-import ProjectProgressBar from '../projectCard/projectProgressBar';
-import { DueDateBox } from '../projectCard/dueDateBox';
-import { DifficultyMessage } from '../mappingLevel';
-import { BigProjectTeaser } from './bigProjectTeaser';
-import { useComputeCompleteness } from '../../hooks/UseProjectCompletenessCalc';
-
-const ProjectDetailTypeBar = (props) => {
- const titleClasses = 'db ttu f7 blue-grey mb2 fw5';
- return (
-
- );
-};
-
-export function ProjectInfoPanel({ project, tasks, contributors, type }: Object) {
- const intl = useIntl();
- const { percentMapped, percentValidated, percentBadImagery } = useComputeCompleteness(tasks);
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/components/projectDetail/infoPanel.jsx b/frontend/src/components/projectDetail/infoPanel.jsx
new file mode 100644
index 0000000000..f887076108
--- /dev/null
+++ b/frontend/src/components/projectDetail/infoPanel.jsx
@@ -0,0 +1,75 @@
+import ReactPlaceholder from 'react-placeholder';
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import messages from './messages';
+import { MappingTypes } from '../mappingTypes';
+import { Imagery } from '../taskSelection/imagery';
+import ProjectProgressBar from '../projectCard/projectProgressBar';
+import { DueDateBox } from '../projectCard/dueDateBox';
+import { DifficultyMessage } from '../mappingLevel';
+import { BigProjectTeaser } from './bigProjectTeaser';
+import { useComputeCompleteness } from '../../hooks/UseProjectCompletenessCalc';
+
+const ProjectDetailTypeBar = (props) => {
+ const titleClasses = 'db ttu f7 blue-grey mb2 fw5';
+ return (
+
+ );
+};
+
+export function ProjectInfoPanel({ project, tasks, contributors, type }) {
+ const intl = useIntl();
+ const { percentMapped, percentValidated, percentBadImagery } = useComputeCompleteness(tasks);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/projectDetail/liveViewButton.js b/frontend/src/components/projectDetail/liveViewButton.jsx
similarity index 100%
rename from frontend/src/components/projectDetail/liveViewButton.js
rename to frontend/src/components/projectDetail/liveViewButton.jsx
diff --git a/frontend/src/components/projectDetail/messages.js b/frontend/src/components/projectDetail/messages.ts
similarity index 100%
rename from frontend/src/components/projectDetail/messages.js
rename to frontend/src/components/projectDetail/messages.ts
diff --git a/frontend/src/components/projectDetail/osmchaButton.js b/frontend/src/components/projectDetail/osmchaButton.js
deleted file mode 100644
index 3734ab88cb..0000000000
--- a/frontend/src/components/projectDetail/osmchaButton.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { CustomButton } from '../button';
-import { ExternalLinkIcon } from '../svgIcons';
-import { formatOSMChaLink } from '../../utils/osmchaLink';
-
-export const OSMChaButton = ({ project, className, compact = false, children }: Object) => (
-
- {children || (
-
- {compact ? (
-
- ) : (
-
- )}
-
-
- )}
-
-);
diff --git a/frontend/src/components/projectDetail/osmchaButton.jsx b/frontend/src/components/projectDetail/osmchaButton.jsx
new file mode 100644
index 0000000000..8a69814d74
--- /dev/null
+++ b/frontend/src/components/projectDetail/osmchaButton.jsx
@@ -0,0 +1,21 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { CustomButton } from '../button';
+import { ExternalLinkIcon } from '../svgIcons';
+import { formatOSMChaLink } from '../../utils/osmchaLink';
+
+export const OSMChaButton = ({ project, className, compact = false, children }) => (
+
+ {children || (
+
+ {compact ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+);
diff --git a/frontend/src/components/projectDetail/permissionBox.js b/frontend/src/components/projectDetail/permissionBox.js
deleted file mode 100644
index 5cfe1d2517..0000000000
--- a/frontend/src/components/projectDetail/permissionBox.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import ReactPlaceholder from 'react-placeholder';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-
-export const PermissionBox = ({ permission, validation = false, className }: Object) => {
- const teamString = (
-
- {validation ? (
-
- ) : (
-
- )}
-
- );
-
- return (
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectDetail/permissionBox.jsx b/frontend/src/components/projectDetail/permissionBox.jsx
new file mode 100644
index 0000000000..8a6a83db9b
--- /dev/null
+++ b/frontend/src/components/projectDetail/permissionBox.jsx
@@ -0,0 +1,33 @@
+import ReactPlaceholder from 'react-placeholder';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+export const PermissionBox = ({ permission, validation = false, className }) => {
+ const teamString = (
+
+ {validation ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectDetail/privateProjectError.js b/frontend/src/components/projectDetail/privateProjectError.jsx
similarity index 100%
rename from frontend/src/components/projectDetail/privateProjectError.js
rename to frontend/src/components/projectDetail/privateProjectError.jsx
diff --git a/frontend/src/components/projectDetail/projectDetailPlaceholder.js b/frontend/src/components/projectDetail/projectDetailPlaceholder.jsx
similarity index 100%
rename from frontend/src/components/projectDetail/projectDetailPlaceholder.js
rename to frontend/src/components/projectDetail/projectDetailPlaceholder.jsx
diff --git a/frontend/src/components/projectDetail/questionsAndComments.js b/frontend/src/components/projectDetail/questionsAndComments.js
deleted file mode 100644
index 4fb083ef64..0000000000
--- a/frontend/src/components/projectDetail/questionsAndComments.js
+++ /dev/null
@@ -1,198 +0,0 @@
-import { lazy, Suspense, useState } from 'react';
-import { useSelector } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-import { useMutation } from '@tanstack/react-query';
-import ReactPlaceholder from 'react-placeholder';
-
-import messages from './messages';
-import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
-import { PaginatorLine } from '../paginator';
-import { Button } from '../button';
-import { Alert } from '../alert';
-import { MessageStatus } from '../comments/status';
-import { UserAvatar } from '../user/avatar';
-import { htmlFromMarkdown, formatUserNamesToLink } from '../../utils/htmlFromMarkdown';
-import { useEditProjectAllowed } from '../../hooks/UsePermissions';
-import { DeleteModal } from '../deleteModal';
-import { postProjectComment, useCommentsQuery } from '../../api/questionsAndComments';
-
-import './styles.scss';
-
-const CommentInputField = lazy(() =>
- import('../comments/commentInput' /* webpackChunkName: "commentInput" */),
-);
-
-export const PostProjectComment = ({ projectId, refetchComments, contributors }) => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
- const [comment, setComment] = useState('');
-
- const mutation = useMutation({
- mutationFn: () => postProjectComment(projectId, comment, token, locale),
- onSuccess: () => {
- refetchComments();
- setComment('');
- },
- });
-
- const saveComment = () => {
- mutation.mutate({ message: comment });
- };
-
- return (
-
-
- }>
- user.username) : undefined
- }
- />
-
-
-
-
- saveComment()}
- className="bg-red white f5"
- disabled={comment === ''}
- loading={mutation.isLoading}
- >
-
-
-
-
-
-
-
- );
-};
-
-export const QuestionsAndComments = ({ project, contributors, titleClass }) => {
- const token = useSelector((state) => state.auth.token);
- const [page, setPage] = useState(1);
- const [userCanEditProject] = useEditProjectAllowed(project);
- const projectId = project.projectId;
-
- const handlePagination = (val) => {
- setPage(val);
- };
-
- const { data: comments, status: commentsStatus, refetch } = useCommentsQuery(projectId, page);
-
- return (
-
-
-
-
-
- {commentsStatus === 'loading' &&
}{' '}
- {commentsStatus === 'error' && (
-
- )}
- {commentsStatus === 'success' && (
- <>
- {comments?.chat.length ? (
-
- ) : (
-
-
-
- )}
- >
- )}
- {comments?.pagination?.pages > 0 && (
-
- )}
- {token ? (
-
- ) : (
-
- )}
-
-
- );
-};
-
-export function CommentList({ userCanEditProject, projectId, comments, retryFn }: Object) {
- const username = useSelector((state) => state.auth.userDetails.username);
-
- return (
-
- {comments.map((comment) => (
-
-
-
-
- {(userCanEditProject || comment.username === username) && (
-
- )}
-
-
-
-
- ))}
-
- );
-}
diff --git a/frontend/src/components/projectDetail/questionsAndComments.jsx b/frontend/src/components/projectDetail/questionsAndComments.jsx
new file mode 100644
index 0000000000..985497518c
--- /dev/null
+++ b/frontend/src/components/projectDetail/questionsAndComments.jsx
@@ -0,0 +1,211 @@
+import { lazy, Suspense, useState, useEffect } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import { FormattedMessage } from 'react-intl';
+import { useMutation } from '@tanstack/react-query';
+import ReactPlaceholder from 'react-placeholder';
+
+import messages from './messages';
+import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
+import { PaginatorLine } from '../paginator';
+import { Button } from '../button';
+import { Alert } from '../alert';
+import { MessageStatus } from '../comments/status';
+import { UserAvatar } from '../user/avatar';
+import { htmlFromMarkdown, formatUserNamesToLink } from '../../utils/htmlFromMarkdown';
+import { useEditProjectAllowed } from '../../hooks/UsePermissions';
+import { DeleteModal } from '../deleteModal';
+import { postProjectComment, useCommentsQuery } from '../../api/questionsAndComments';
+
+import './styles.scss';
+
+const CommentInputField = lazy(() =>
+ import('../comments/commentInput' /* webpackChunkName: "commentInput" */),
+);
+
+export const PostProjectComment = ({ projectId, refetchComments, contributors }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+ const locale = useTypedSelector((state) => state.preferences['locale']);
+ const [comment, setComment] = useState('');
+
+ const mutation = useMutation({
+ mutationFn: () => postProjectComment(projectId, comment, token, locale),
+ onSuccess: () => {
+ refetchComments();
+ setComment('');
+ },
+ });
+
+ const saveComment = () => {
+ mutation.mutate({ message: comment });
+ };
+
+ return (
+
+
+ }>
+ user.username) : undefined
+ }
+ />
+
+
+
+
+ saveComment()}
+ className="bg-red white f5"
+ disabled={comment === ''}
+ loading={mutation.isLoading}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+export const QuestionsAndComments = ({ project, contributors, titleClass }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+ const [page, setPage] = useState(1);
+ const [userCanEditProject] = useEditProjectAllowed(project);
+ const projectId = project.projectId;
+
+ const handlePagination = (val) => {
+ setPage(val);
+ };
+
+ const { data: comments, status: commentsStatus, refetch } = useCommentsQuery(projectId, page);
+
+ return (
+
+
+
+
+
+ {commentsStatus === 'pending' &&
}{' '}
+ {commentsStatus === 'error' && (
+
+ )}
+ {commentsStatus === 'success' && (
+ <>
+ {comments?.chat.length ? (
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+ {comments?.pagination?.pages > 0 && (
+
+ )}
+ {token ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export function CommentList({ userCanEditProject, projectId, comments, retryFn }) {
+ const username = useTypedSelector((state) => state.auth.userDetails?.username);
+ const [commentsMessageHTML, setCommentsMessageHTML] = useState([]);
+
+ useEffect(() => {
+ if (!comments) return;
+ (async () => {
+ for (const comment of comments) {
+ const html = await htmlFromMarkdown(formatUserNamesToLink(comment.message));
+ setCommentsMessageHTML((prev) => [...prev, html]);
+ }
+ })();
+ }, [comments]);
+
+ return (
+
+ {comments.map((comment, index) => (
+
+
+
+
+ {(userCanEditProject || comment.username === username) && (
+
+ )}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/projectDetail/shareButton.js b/frontend/src/components/projectDetail/shareButton.js
deleted file mode 100644
index acdce7cf4b..0000000000
--- a/frontend/src/components/projectDetail/shareButton.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Tooltip } from 'react-tooltip';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { ORG_CODE } from '../../config';
-import { createPopup } from '../../utils/login';
-import { getTwitterLink, getLinkedInLink, getFacebookLink } from '../../utils/shareFunctions';
-import { TwitterIcon, FacebookIcon, LinkedinIcon, ShareIcon } from '../svgIcons';
-
-export function ShareButton({ projectId }: Object) {
- const iconStyle = { width: '1.4em', height: '1.4em' };
-
- const twitterPopup = (message) =>
- createPopup(
- 'twitter',
- getTwitterLink(message, window.location.href, [ORG_CODE, 'OpenStreetMap']),
- );
-
- const facebookPopup = (message) =>
- createPopup('facebook', getFacebookLink(message, window.location.href));
-
- const linkedInPopup = () => createPopup('linkedin', getLinkedInLink(window.location.href));
-
- return (
- <>
-
-
-
-
-
-
-
-
- {(msg) => (
- <>
- twitterPopup(msg)}
- >
-
- Tweet
-
- facebookPopup(msg)}
- >
-
-
-
- >
- )}
-
- linkedInPopup()}>
-
-
-
-
- >
- );
-}
diff --git a/frontend/src/components/projectDetail/shareButton.jsx b/frontend/src/components/projectDetail/shareButton.jsx
new file mode 100644
index 0000000000..65e3a8a57a
--- /dev/null
+++ b/frontend/src/components/projectDetail/shareButton.jsx
@@ -0,0 +1,63 @@
+import { Tooltip } from 'react-tooltip';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { ORG_CODE } from '../../config';
+import { createPopup } from '../../utils/login';
+import { getTwitterLink, getLinkedInLink, getFacebookLink } from '../../utils/shareFunctions';
+import { TwitterIcon, FacebookIcon, LinkedinIcon, ShareIcon } from '../svgIcons';
+
+export function ShareButton({ projectId }) {
+ const iconStyle = { width: '1.4em', height: '1.4em' };
+
+ const twitterPopup = (message) =>
+ createPopup(
+ 'twitter',
+ getTwitterLink(message, window.location.href, [ORG_CODE, 'OpenStreetMap']),
+ );
+
+ const facebookPopup = (message) =>
+ createPopup('facebook', getFacebookLink(message, window.location.href));
+
+ const linkedInPopup = () => createPopup('linkedin', getLinkedInLink(window.location.href));
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {(msg) => (
+ <>
+ twitterPopup(msg)}
+ >
+
+ Tweet
+
+ facebookPopup(msg)}
+ >
+
+
+
+ >
+ )}
+
+ linkedInPopup()}>
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/projectDetail/similarProjects.js b/frontend/src/components/projectDetail/similarProjects.js
deleted file mode 100644
index 5a1893982c..0000000000
--- a/frontend/src/components/projectDetail/similarProjects.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import ReactPlaceholder from 'react-placeholder';
-import { FormattedMessage } from 'react-intl';
-
-import { useFetch } from '../../hooks/UseFetch';
-import { ProjectCard } from '../projectCard/projectCard';
-import { nCardPlaceholders } from '../projectCard/nCardPlaceholder';
-import messages from './messages';
-
-export const SimilarProjects = ({ projectId }) => {
- const [error, loading, similarProjects] = useFetch(
- `projects/queries/${projectId}/similar-projects/?limit=4`,
- );
-
- return (
-
- {!loading && (error || similarProjects.results.length === 0) ? (
-
-
-
- ) : (
-
-
- {similarProjects.results?.map((project) => (
-
- ))}
-
-
- )}
-
- );
-};
diff --git a/frontend/src/components/projectDetail/similarProjects.jsx b/frontend/src/components/projectDetail/similarProjects.jsx
new file mode 100644
index 0000000000..4a59bbe8d9
--- /dev/null
+++ b/frontend/src/components/projectDetail/similarProjects.jsx
@@ -0,0 +1,36 @@
+import ReactPlaceholder from 'react-placeholder';
+import { FormattedMessage } from 'react-intl';
+
+import { useFetch } from '../../hooks/UseFetch';
+import { ProjectCard } from '../projectCard/projectCard';
+import { nCardPlaceholders } from '../projectCard/nCardPlaceholder';
+import messages from './messages';
+
+export const SimilarProjects = ({ projectId }) => {
+ const [error, loading, similarProjects] = useFetch(
+ `projects/queries/${projectId}/similar-projects/?limit=4`,
+ );
+
+ return (
+
+ {!loading && (error || similarProjects.results.length === 0) ? (
+
+
+
+ ) : (
+
+
+ {similarProjects.results?.map((project) => (
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/frontend/src/components/projectDetail/statusBox.js b/frontend/src/components/projectDetail/statusBox.js
deleted file mode 100644
index 35d1a27343..0000000000
--- a/frontend/src/components/projectDetail/statusBox.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-
-export const ProjectStatusBox = ({ status, className }: Object) => {
- const colour = status === 'DRAFT' ? 'orange' : 'blue-grey';
- return (
-
-
-
- );
-};
diff --git a/frontend/src/components/projectDetail/statusBox.tsx b/frontend/src/components/projectDetail/statusBox.tsx
new file mode 100644
index 0000000000..a073d078ce
--- /dev/null
+++ b/frontend/src/components/projectDetail/statusBox.tsx
@@ -0,0 +1,15 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+export const ProjectStatusBox = ({ status, className }: {
+ status: "DRAFT" | "PUBLISHED" | "ARCHIVED",
+ className: string,
+}) => {
+ const colour = status === 'DRAFT' ? 'orange' : 'blue-grey';
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js b/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js
deleted file mode 100644
index 25543a6751..0000000000
--- a/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { BigProjectTeaser } from '../bigProjectTeaser';
-import { IntlProviders } from '../../../utils/testWithIntl';
-
-describe('BigProjectTeaser component', () => {
- it('shows 5 total contributors for project last updated 1 minute ago', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('5')).toBeInTheDocument();
- expect(screen.getByText(/contributors/)).toBeInTheDocument();
- expect(screen.getByText(/Last contribution 1 minute ago/)).toBeInTheDocument();
- });
-
- it('shows 1 total contributor for project last updated a second ago', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('1')).toBeInTheDocument();
- expect(screen.getByText(/contributor/)).toBeInTheDocument();
- expect(screen.getByText(/Last contribution 1 second ago/)).toBeInTheDocument();
- });
-
- it('shows no contributors yet for project with no mapping or validation and last updated 4 days ago', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument();
- expect(screen.getByText(/Last contribution 4 days ago/)).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.jsx b/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.jsx
new file mode 100644
index 0000000000..d921aa0424
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/bigProjectTeaser.test.jsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { BigProjectTeaser } from '../bigProjectTeaser';
+import { IntlProviders } from '../../../utils/testWithIntl';
+
+describe('BigProjectTeaser component', () => {
+ it('shows 5 total contributors for project last updated 1 minute ago', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('5')).toBeInTheDocument();
+ expect(screen.getByText(/contributors/)).toBeInTheDocument();
+ expect(screen.getByText(/Last contribution 1 minute ago/)).toBeInTheDocument();
+ });
+
+ it('shows 1 total contributor for project last updated a second ago', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('1')).toBeInTheDocument();
+ expect(screen.getByText(/contributor/)).toBeInTheDocument();
+ expect(screen.getByText(/Last contribution 1 second ago/)).toBeInTheDocument();
+ });
+
+ it('shows no contributors yet for project with no mapping or validation and last updated 4 days ago', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument();
+ expect(screen.getByText(/Last contribution 4 days ago/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/downloadButtons.test.js b/frontend/src/components/projectDetail/tests/downloadButtons.test.js
deleted file mode 100644
index a4e0b08ead..0000000000
--- a/frontend/src/components/projectDetail/tests/downloadButtons.test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { DownloadAOIButton, DownloadTaskGridButton } from '../downloadButtons';
-import { IntlProviders } from '../../../utils/testWithIntl';
-
-describe('tests DownloadAOI and DownloadTasksGrid buttons', () => {
- it('displays button to download AOI for project with id 1', () => {
- const { container } = render(
-
-
- ,
- );
- expect(container.querySelector('a').href).toContain('projects/1/queries/aoi/?as_file=true');
- expect(container.querySelector('a').download).toBe('project-1-aoi.geojson');
- expect(container.querySelector('svg')).toBeInTheDocument();
- expect(screen.getByText(/Download AOI/)).toBeInTheDocument();
- expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument();
- });
-
- it('displays button to download Task Grid for project with id 2', () => {
- const { container } = render(
-
-
- ,
- );
- expect(container.querySelector('a').href).toContain('projects/2/tasks/?as_file=true');
- expect(container.querySelector('a').download).toBe('project-2-tasks.geojson');
- expect(container.querySelector('svg')).toBeInTheDocument();
- expect(screen.getByText(/Download Tasks Grid/)).toBeInTheDocument();
- expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/downloadButtons.test.jsx b/frontend/src/components/projectDetail/tests/downloadButtons.test.jsx
new file mode 100644
index 0000000000..4beb053122
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/downloadButtons.test.jsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { DownloadAOIButton, DownloadTaskGridButton } from '../downloadButtons';
+import { IntlProviders } from '../../../utils/testWithIntl';
+
+describe('tests DownloadAOI and DownloadTasksGrid buttons', () => {
+ it('displays button to download AOI for project with id 1', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.querySelector('a').href).toContain('projects/1/queries/aoi/?as_file=true');
+ expect(container.querySelector('a').download).toBe('project-1-aoi.geojson');
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ expect(screen.getByText(/Download AOI/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument();
+ });
+
+ it('displays button to download Task Grid for project with id 2', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.querySelector('a').href).toContain('projects/2/tasks/?as_file=true');
+ expect(container.querySelector('a').download).toBe('project-2-tasks.geojson');
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ expect(screen.getByText(/Download Tasks Grid/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/favorites.test.js b/frontend/src/components/projectDetail/tests/favorites.test.js
deleted file mode 100644
index 9d6eec8e0e..0000000000
--- a/frontend/src/components/projectDetail/tests/favorites.test.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import '@testing-library/jest-dom';
-import { act, screen, waitFor } from '@testing-library/react';
-
-import { store } from '../../../store';
-import {
- ReduxIntlProviders,
- renderWithRouter,
- createComponentWithMemoryRouter,
-} from '../../../utils/testWithIntl';
-import { AddToFavorites } from '../favorites';
-import messages from '../messages';
-
-describe('AddToFavorites button', () => {
- it('renders button when project id = 1', async () => {
- const props = {
- projectId: 1,
- };
- const { container } = renderWithRouter(
-
-
- ,
- );
- const button = screen.getByRole('button');
- expect(button.className).toBe(
- ' input-reset base-font bg-white blue-dark bn pointer flex nowrap items-center ml3',
- );
- expect(button.className).not.toBe('dn input-reset base-font bg-white blue-dark f6 bn pointer');
- expect(container.querySelector('svg').classList.value).toBe('pr2 v-btm o-50 blue-grey');
- expect(button.textContent).toBe('Add to Favorites');
- });
-
- it('should navigate to login page if the user is not logged in', async () => {
- act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: null });
- });
- const { user, router } = createComponentWithMemoryRouter(
-
-
- ,
- );
- await user.click(screen.getByRole('button'));
- expect(router.state.location.pathname).toBe('/login');
- });
-
- it('should mark the project as favorite', async () => {
- act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
- });
- const { user } = createComponentWithMemoryRouter(
-
-
- ,
- );
- expect(screen.getByText(messages.addToFavorites.defaultMessage)).toBeInTheDocument();
- await user.click(screen.getByRole('button'));
- await waitFor(() =>
- expect(screen.queryByText(messages.addToFavorites.defaultMessage)).not.toBeInTheDocument(),
- );
- expect(screen.getByText(messages.removeFromFavorites.defaultMessage)).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/favorites.test.jsx b/frontend/src/components/projectDetail/tests/favorites.test.jsx
new file mode 100644
index 0000000000..0e2188d709
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/favorites.test.jsx
@@ -0,0 +1,61 @@
+
+import { act, screen, waitFor } from '@testing-library/react';
+
+import { store } from '../../../store';
+import {
+ ReduxIntlProviders,
+ renderWithRouter,
+ createComponentWithMemoryRouter,
+} from '../../../utils/testWithIntl';
+import { AddToFavorites } from '../favorites';
+import messages from '../messages';
+
+describe('AddToFavorites button', () => {
+ it('renders button when project id = 1', async () => {
+ const props = {
+ projectId: 1,
+ };
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ const button = screen.getByRole('button');
+ expect(button.className).toBe(
+ ' input-reset base-font bg-white blue-dark bn pointer flex nowrap items-center ml3',
+ );
+ expect(button.className).not.toBe('dn input-reset base-font bg-white blue-dark f6 bn pointer');
+ expect(container.querySelector('svg').classList.value).toBe('pr2 v-btm o-50 blue-grey');
+ expect(button.textContent).toBe('Add to Favorites');
+ });
+
+ it('should navigate to login page if the user is not logged in', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: null });
+ });
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+ ,
+ );
+ await user.click(screen.getByRole('button'));
+ expect(router.state.location.pathname).toBe('/login');
+ });
+
+ it('should mark the project as favorite', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ });
+ const { user } = createComponentWithMemoryRouter(
+
+
+
+ );
+ expect(screen.getByText(messages.addToFavorites.defaultMessage)).toBeInTheDocument();
+ await user.click(await screen.findByRole('button'));
+ await waitFor(() =>
+ expect(screen.queryByText(messages.addToFavorites.defaultMessage)).not.toBeInTheDocument(),
+ );
+ expect(screen.getByText(messages.removeFromFavorites.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/footer.test.js b/frontend/src/components/projectDetail/tests/footer.test.js
deleted file mode 100644
index e73ac8a9f3..0000000000
--- a/frontend/src/components/projectDetail/tests/footer.test.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { ProjectDetailFooter } from '../footer';
-
-describe('test if project detail footer', () => {
- const props = {
- projectId: 1,
- className: '',
- };
- it('renders footer for project with id 1', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(/Overview/)).toBeInTheDocument();
- expect(screen.getByText(/Description/)).toBeInTheDocument();
- expect(screen.getByText(/Coordination/)).toBeInTheDocument();
- expect(screen.getByText(/Teams & Permissions/)).toBeInTheDocument();
- expect(screen.getByText(/Questions and comments/)).toBeInTheDocument();
- expect(screen.getByText(/Contributions/)).toBeInTheDocument();
- expect(screen.getByText('Share')).toBeInTheDocument();
- const contributeBtn = screen.getByRole('button', { pressed: false });
- const link = contributeBtn.closest('a');
- expect(link.href).toContain('/tasks');
- expect(contributeBtn.textContent).toBe('Contribute');
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/footer.test.jsx b/frontend/src/components/projectDetail/tests/footer.test.jsx
new file mode 100644
index 0000000000..2df20ef395
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/footer.test.jsx
@@ -0,0 +1,30 @@
+import { screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { ProjectDetailFooter } from '../footer';
+
+describe('test if project detail footer', () => {
+ const props = {
+ projectId: 1,
+ className: '',
+ };
+ it('renders footer for project with id 1', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText(/Overview/)).toBeInTheDocument();
+ expect(screen.getByText(/Description/)).toBeInTheDocument();
+ expect(screen.getByText(/Coordination/)).toBeInTheDocument();
+ expect(screen.getByText(/Teams & Permissions/)).toBeInTheDocument();
+ expect(screen.getByText(/Questions and comments/)).toBeInTheDocument();
+ expect(screen.getByText(/Contributions/)).toBeInTheDocument();
+ expect(screen.getByText('Share')).toBeInTheDocument();
+ const contributeBtn = screen.getByRole('button', { pressed: false });
+ const link = contributeBtn.closest('a');
+ expect(link.href).toContain('/tasks');
+ expect(contributeBtn.textContent).toBe('Contribute');
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/header.test.js b/frontend/src/components/projectDetail/tests/header.test.js
deleted file mode 100644
index f15dd7a17d..0000000000
--- a/frontend/src/components/projectDetail/tests/header.test.js
+++ /dev/null
@@ -1,173 +0,0 @@
-import { screen, act, render } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { HeaderLine, ProjectHeader, TagLine } from '../header';
-import { ReduxIntlProviders, IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { getProjectSummary } from '../../../network/tests/mockData/projects';
-import { store } from '../../../store';
-
-describe('test if HeaderLine component', () => {
- it('shows id 2 and HIGH priority status for a HOT project to a user with edit rights', () => {
- renderWithRouter(
-
-
- ,
- );
-
- expect(screen.getByText('#2')).toBeInTheDocument();
- expect(screen.getByText('#2').closest('a').href).toContain('projects/2');
- expect(screen.getByText('| HOT')).toBeInTheDocument();
- expect(screen.getByText('Edit project')).toBeInTheDocument();
- expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/2');
- expect(screen.getByText('High')).toBeInTheDocument();
- });
-
- it('shows id 1 for a LOW priority HOT project to a user with no edit rights', () => {
- renderWithRouter(
-
-
- ,
- );
-
- expect(screen.getByText('#1')).toBeInTheDocument();
- expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
- expect(screen.getByText('| HOT')).toBeInTheDocument();
- expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
- expect(screen.queryByText('Low')).toBeInTheDocument();
- });
-});
-
-describe('test if ProjectHeader component', () => {
- const project = getProjectSummary(1);
- it('shows Header for urgent priority project for logged in project author', () => {
- act(() => {
- store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { username: 'test_user' },
- });
- });
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('#1')).toBeInTheDocument();
- expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
- expect(screen.getByText('| HOT')).toBeInTheDocument();
- expect(screen.getByText('Edit project')).toBeInTheDocument();
- expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/1');
- expect(screen.getByText('Urgent')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
- expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
- expect(screen.getByText(/Women security/i)).toBeInTheDocument();
- expect(screen.getByText('Bolivia')).toBeInTheDocument();
- expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
- });
-
- it('shows Header for urgent priority project for non-logged in user', () => {
- act(() => {
- store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
- });
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('#1')).toBeInTheDocument();
- expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
- expect(screen.getByText('| HOT')).toBeInTheDocument();
- expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
- expect(screen.getByText('Urgent')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
- expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
- expect(screen.getByText(/Women security/i)).toBeInTheDocument();
- expect(screen.getByText('Bolivia')).toBeInTheDocument();
- expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
- });
-
- it('shows Header for low priority draft project for logged in user', () => {
- act(() => {
- store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { username: 'user123' },
- });
- });
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('#1')).toBeInTheDocument();
- expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
- expect(screen.getByText('| HOT')).toBeInTheDocument();
- expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
- expect(screen.queryByText('Low')).toBeInTheDocument();
- expect(screen.queryByText('Draft')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
- expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
- expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
- expect(screen.getByText('Bolivia')).toBeInTheDocument();
- expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
- });
-});
-
-describe('TagLine', () => {
- it('renders tags with proper formatting', () => {
- const campaigns = [{ name: 'Campaign 1' }, { name: 'Campaign 2' }];
- const countries = ['Country 1'];
- const interests = [{ name: 'Interest 1' }, { name: 'Interest 2' }];
-
- const { container } = render(
-
-
- ,
- );
-
- const tagLineElement = container.querySelector('.blue-light');
- const tagElements = tagLineElement.querySelectorAll('span');
- expect(tagElements.length).toBe(5);
- expect(tagElements[0].textContent).toBe('Campaign 1, Campaign 2');
- expect(tagElements[1].textContent).toBe('·Country 1');
- expect(tagElements[2].textContent).toBe('·');
- expect(tagElements[3].textContent).toBe('·Interest 1, Interest 2');
- expect(tagElements[4].textContent).toBe('·');
- });
-
- it('renders tags without bullet separators if there is only one tag', () => {
- const campaigns = [{ name: 'Campaign 1' }];
- const countries = [];
- const interests = [];
-
- const { container } = render(
-
-
- ,
- );
- const tagLineElement = container.querySelector('.blue-light');
- const tagElements = tagLineElement.querySelectorAll('span');
- expect(tagElements.length).toBe(1);
- expect(tagElements[0].textContent).toBe('Campaign 1');
- });
-
- it('renders an empty tag line if no tags are provided', () => {
- const campaigns = [];
- const countries = [];
- const interests = [];
-
- const { container } = render(
-
-
- ,
- );
- const tagLineElement = container.querySelector('.blue-light');
- const tagElements = tagLineElement.querySelectorAll('span');
- expect(tagElements.length).toBe(0);
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/header.test.jsx b/frontend/src/components/projectDetail/tests/header.test.jsx
new file mode 100644
index 0000000000..d48d6719dc
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/header.test.jsx
@@ -0,0 +1,173 @@
+import { screen, act, render } from '@testing-library/react';
+
+
+import { HeaderLine, ProjectHeader, TagLine } from '../header';
+import { ReduxIntlProviders, IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { getProjectSummary } from '../../../network/tests/mockData/projects';
+import { store } from '../../../store';
+
+describe('test if HeaderLine component', () => {
+ it('shows id 2 and HIGH priority status for a HOT project to a user with edit rights', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+
+ expect(screen.getByText('#2')).toBeInTheDocument();
+ expect(screen.getByText('#2').closest('a').href).toContain('projects/2');
+ expect(screen.getByText('| HOT')).toBeInTheDocument();
+ expect(screen.getByText('Edit project')).toBeInTheDocument();
+ expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/2');
+ expect(screen.getByText('High')).toBeInTheDocument();
+ });
+
+ it('shows id 1 for a LOW priority HOT project to a user with no edit rights', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+
+ expect(screen.getByText('#1')).toBeInTheDocument();
+ expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
+ expect(screen.getByText('| HOT')).toBeInTheDocument();
+ expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
+ expect(screen.queryByText('Low')).toBeInTheDocument();
+ });
+});
+
+describe('test if ProjectHeader component', () => {
+ const project = getProjectSummary(1);
+ it('shows Header for urgent priority project for logged in project author', () => {
+ act(() => {
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { username: 'test_user' },
+ });
+ });
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('#1')).toBeInTheDocument();
+ expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
+ expect(screen.getByText('| HOT')).toBeInTheDocument();
+ expect(screen.getByText('Edit project')).toBeInTheDocument();
+ expect(screen.getByText('Edit project').closest('a').href).toContain('/manage/projects/1');
+ expect(screen.getByText('Urgent')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
+ expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
+ expect(screen.getByText(/Women security/i)).toBeInTheDocument();
+ expect(screen.getByText('Bolivia')).toBeInTheDocument();
+ expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
+ });
+
+ it('shows Header for urgent priority project for non-logged in user', () => {
+ act(() => {
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ });
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('#1')).toBeInTheDocument();
+ expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
+ expect(screen.getByText('| HOT')).toBeInTheDocument();
+ expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
+ expect(screen.getByText('Urgent')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
+ expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
+ expect(screen.getByText(/Women security/i)).toBeInTheDocument();
+ expect(screen.getByText('Bolivia')).toBeInTheDocument();
+ expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
+ });
+
+ it('shows Header for low priority draft project for logged in user', () => {
+ act(() => {
+ store.dispatch({ type: 'SET_LOCALE', locale: 'en-US' });
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { username: 'user123' },
+ });
+ });
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('#1')).toBeInTheDocument();
+ expect(screen.getByText('#1').closest('a').href).toContain('projects/1');
+ expect(screen.getByText('| HOT')).toBeInTheDocument();
+ expect(screen.queryByText('Edit project')).not.toBeInTheDocument();
+ expect(screen.queryByText('Low')).toBeInTheDocument();
+ expect(screen.queryByText('Draft')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings')).toBeInTheDocument();
+ expect(screen.getByText('La Paz Buildings').closest('h3').lang).toBe('en');
+ expect(screen.getByText(/Environment Conservation/i)).toBeInTheDocument();
+ expect(screen.getByText('Bolivia')).toBeInTheDocument();
+ expect(screen.queryByText(/private/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('TagLine', () => {
+ it('renders tags with proper formatting', () => {
+ const campaigns = [{ name: 'Campaign 1' }, { name: 'Campaign 2' }];
+ const countries = ['Country 1'];
+ const interests = [{ name: 'Interest 1' }, { name: 'Interest 2' }];
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const tagLineElement = container.querySelector('.blue-light');
+ const tagElements = tagLineElement.querySelectorAll('span');
+ expect(tagElements.length).toBe(5);
+ expect(tagElements[0].textContent).toBe('Campaign 1, Campaign 2');
+ expect(tagElements[1].textContent).toBe('·Country 1');
+ expect(tagElements[2].textContent).toBe('·');
+ expect(tagElements[3].textContent).toBe('·Interest 1, Interest 2');
+ expect(tagElements[4].textContent).toBe('·');
+ });
+
+ it('renders tags without bullet separators if there is only one tag', () => {
+ const campaigns = [{ name: 'Campaign 1' }];
+ const countries = [];
+ const interests = [];
+
+ const { container } = render(
+
+
+ ,
+ );
+ const tagLineElement = container.querySelector('.blue-light');
+ const tagElements = tagLineElement.querySelectorAll('span');
+ expect(tagElements.length).toBe(1);
+ expect(tagElements[0].textContent).toBe('Campaign 1');
+ });
+
+ it('renders an empty tag line if no tags are provided', () => {
+ const campaigns = [];
+ const countries = [];
+ const interests = [];
+
+ const { container } = render(
+
+
+ ,
+ );
+ const tagLineElement = container.querySelector('.blue-light');
+ const tagElements = tagLineElement.querySelectorAll('span');
+ expect(tagElements.length).toBe(0);
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/infoPanel.test.js b/frontend/src/components/projectDetail/tests/infoPanel.test.js
deleted file mode 100644
index 91a5f8b15d..0000000000
--- a/frontend/src/components/projectDetail/tests/infoPanel.test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ProjectInfoPanel } from '../infoPanel';
-import { IntlProviders } from '../../../utils/testWithIntl';
-import { getProjectSummary } from '../../../network/tests/mockData/projects';
-
-describe('if projectInfoPanel', () => {
- const contributors = [
- {
- username: 'test_user',
- mappingLevel: 'ADVANCED',
- pictureUrl: null,
- mapped: 2,
- validated: 0,
- total: 2,
- mappedTasks: [1, 2],
- validatedTasks: [],
- name: 'Test',
- dateRegistered: new Date(),
- },
- ];
-
- const tasks = {
- features: [
- {
- geometry: {
- coordinates: [
- [
- [
- [-71.485823338, 1.741751328],
- [-71.485899664, 1.741550711],
- [-71.485954761, 1.741751328],
- [-71.485823338, 1.741751328],
- ],
- ],
- ],
- type: 'MultiPolygon',
- },
- properties: {
- lockedBy: null,
- taskId: 2,
- taskIsSquare: false,
- taskStatus: 'MAPPED',
- taskX: 158035,
- taskY: 264680,
- taskZoom: 19,
- },
- type: 'Feature',
- },
- {
- geometry: {
- coordinates: [
- [
- [
- [-71.485686012, 1.742112276],
- [-71.485823338, 1.741751328],
- [-71.485954761, 1.741751328],
- [-71.48597717, 1.741832923],
- [-71.48597717, 1.741970313],
- [-71.485686012, 1.742112276],
- ],
- ],
- ],
- type: 'MultiPolygon',
- },
- properties: {
- lockedBy: null,
- taskId: 3,
- taskIsSquare: false,
- taskStatus: 'READY',
- taskX: 158035,
- taskY: 264681,
- taskZoom: 19,
- },
- type: 'Feature',
- },
- ],
- type: 'FeatureCollection',
- };
-
- const project = { ...getProjectSummary(2), lastUpdated: Date.now() - 1e3 * 60 * 60 };
-
- it('renders panel for a beginner mapper project with 1 contributor using any imagery', () => {
- render(
-
-
- ,
- );
-
- expect(screen.getByText('Types of Mapping')).toBeInTheDocument();
- expect(screen.getByText('Imagery')).toBeInTheDocument();
- expect(screen.queryByText('Any available source')).toBeInTheDocument();
- expect(screen.queryByText('1')).toBeInTheDocument();
- expect(screen.queryByText('contributor')).toBeInTheDocument();
- expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument();
- expect(screen.queryByText('Easy')).toBeInTheDocument();
- });
-
- it('renders new immediate mapper project with no contributors yet and using Custom imagery', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('Types of Mapping')).toBeInTheDocument();
- expect(screen.getByText('Imagery')).toBeInTheDocument();
- expect(screen.queryByText('Mapbox Satellite')).toBeInTheDocument();
- expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument();
- expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument();
- expect(screen.queryByText('Moderate')).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/infoPanel.test.jsx b/frontend/src/components/projectDetail/tests/infoPanel.test.jsx
new file mode 100644
index 0000000000..81874ab63d
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/infoPanel.test.jsx
@@ -0,0 +1,123 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ProjectInfoPanel } from '../infoPanel';
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { getProjectSummary } from '../../../network/tests/mockData/projects';
+
+describe('if projectInfoPanel', () => {
+ const contributors = [
+ {
+ username: 'test_user',
+ mappingLevel: 'ADVANCED',
+ pictureUrl: null,
+ mapped: 2,
+ validated: 0,
+ total: 2,
+ mappedTasks: [1, 2],
+ validatedTasks: [],
+ name: 'Test',
+ dateRegistered: new Date(),
+ },
+ ];
+
+ const tasks = {
+ features: [
+ {
+ geometry: {
+ coordinates: [
+ [
+ [
+ [-71.485823338, 1.741751328],
+ [-71.485899664, 1.741550711],
+ [-71.485954761, 1.741751328],
+ [-71.485823338, 1.741751328],
+ ],
+ ],
+ ],
+ type: 'MultiPolygon',
+ },
+ properties: {
+ lockedBy: null,
+ taskId: 2,
+ taskIsSquare: false,
+ taskStatus: 'MAPPED',
+ taskX: 158035,
+ taskY: 264680,
+ taskZoom: 19,
+ },
+ type: 'Feature',
+ },
+ {
+ geometry: {
+ coordinates: [
+ [
+ [
+ [-71.485686012, 1.742112276],
+ [-71.485823338, 1.741751328],
+ [-71.485954761, 1.741751328],
+ [-71.48597717, 1.741832923],
+ [-71.48597717, 1.741970313],
+ [-71.485686012, 1.742112276],
+ ],
+ ],
+ ],
+ type: 'MultiPolygon',
+ },
+ properties: {
+ lockedBy: null,
+ taskId: 3,
+ taskIsSquare: false,
+ taskStatus: 'READY',
+ taskX: 158035,
+ taskY: 264681,
+ taskZoom: 19,
+ },
+ type: 'Feature',
+ },
+ ],
+ type: 'FeatureCollection',
+ };
+
+ const project = { ...getProjectSummary(2), lastUpdated: Date.now() - 1e3 * 60 * 60 };
+
+ it('renders panel for a beginner mapper project with 1 contributor using any imagery', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Types of Mapping')).toBeInTheDocument();
+ expect(screen.getByText('Imagery')).toBeInTheDocument();
+ expect(screen.queryByText('Any available source')).toBeInTheDocument();
+ expect(screen.queryByText('1')).toBeInTheDocument();
+ expect(screen.queryByText('contributor')).toBeInTheDocument();
+ expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument();
+ expect(screen.queryByText('Easy')).toBeInTheDocument();
+ });
+
+ it('renders new immediate mapper project with no contributors yet and using Custom imagery', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Types of Mapping')).toBeInTheDocument();
+ expect(screen.getByText('Imagery')).toBeInTheDocument();
+ expect(screen.queryByText('Mapbox Satellite')).toBeInTheDocument();
+ expect(screen.queryByText(/No contributors yet/)).toBeInTheDocument();
+ expect(screen.queryByText('Last contribution 1 hour ago')).toBeInTheDocument();
+ expect(screen.queryByText('Moderate')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/osmchaButton.test.js b/frontend/src/components/projectDetail/tests/osmchaButton.test.js
deleted file mode 100644
index fcb893f12f..0000000000
--- a/frontend/src/components/projectDetail/tests/osmchaButton.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import { formatOSMChaLink } from '../../../utils/osmchaLink';
-import { OSMChaButton } from '../osmchaButton';
-
-test('OSMChaButton with compact False', () => {
- const project = {
- osmchaFilterId: null,
- aoiBBOX: [0, 0, 1, 1],
- changesetComment: '#TM4-TEST',
- created: '2019-08-27T12:20:42.460024Z',
- };
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('Changesets in OSMCha').className).toBe('pl2 ba b--red br1 f5 pointer');
- expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
- expect(container.querySelector('svg.pl2')).toBeInTheDocument();
- expect(container.querySelector('svg.pl1')).not.toBeInTheDocument();
-});
-
-test('OSMChaButton with compact True', () => {
- const project = {
- osmchaFilterId: null,
- aoiBBOX: [0, 0, 1, 1],
- changesetComment: '#TM4-TEST',
- created: '2019-08-27T12:20:42.460024Z',
- };
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('Changesets').className).toBe('pl3 br1 f5 pointer');
- expect(screen.queryByText('Changesets in OSMCha')).not.toBeInTheDocument();
- expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
- expect(container.querySelector('svg.pl1')).toBeInTheDocument();
- expect(container.querySelector('svg.pl2')).not.toBeInTheDocument();
-});
-
-test('OSMChaButton with children', () => {
- const project = {
- osmchaFilterId: null,
- aoiBBOX: [0, 0, 1, 1],
- changesetComment: '#TM4-TEST',
- created: '2019-08-27T12:20:42.460024Z',
- };
- const { container } = render(
-
-
- Custom text
-
- ,
- );
- expect(screen.queryByText('Changesets')).not.toBeInTheDocument();
- expect(screen.queryByText('Changesets in OSMCha')).not.toBeInTheDocument();
- expect(screen.queryByText('Custom text')).toBeInTheDocument();
- expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
- expect(container.querySelector('svg.pl1')).not.toBeInTheDocument();
- expect(container.querySelector('svg.pl2')).not.toBeInTheDocument();
-});
diff --git a/frontend/src/components/projectDetail/tests/osmchaButton.test.jsx b/frontend/src/components/projectDetail/tests/osmchaButton.test.jsx
new file mode 100644
index 0000000000..ba81b1a1ca
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/osmchaButton.test.jsx
@@ -0,0 +1,65 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import { formatOSMChaLink } from '../../../utils/osmchaLink';
+import { OSMChaButton } from '../osmchaButton';
+
+test('OSMChaButton with compact False', () => {
+ const project = {
+ osmchaFilterId: null,
+ aoiBBOX: [0, 0, 1, 1],
+ changesetComment: '#TM4-TEST',
+ created: '2019-08-27T12:20:42.460024Z',
+ };
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('Changesets in OSMCha').className).toBe('pl2 ba b--red br1 f5 pointer');
+ expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
+ expect(container.querySelector('svg.pl2')).toBeInTheDocument();
+ expect(container.querySelector('svg.pl1')).not.toBeInTheDocument();
+});
+
+test('OSMChaButton with compact True', () => {
+ const project = {
+ osmchaFilterId: null,
+ aoiBBOX: [0, 0, 1, 1],
+ changesetComment: '#TM4-TEST',
+ created: '2019-08-27T12:20:42.460024Z',
+ };
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('Changesets').className).toBe('pl3 br1 f5 pointer');
+ expect(screen.queryByText('Changesets in OSMCha')).not.toBeInTheDocument();
+ expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
+ expect(container.querySelector('svg.pl1')).toBeInTheDocument();
+ expect(container.querySelector('svg.pl2')).not.toBeInTheDocument();
+});
+
+test('OSMChaButton with children', () => {
+ const project = {
+ osmchaFilterId: null,
+ aoiBBOX: [0, 0, 1, 1],
+ changesetComment: '#TM4-TEST',
+ created: '2019-08-27T12:20:42.460024Z',
+ };
+ const { container } = render(
+
+
+ Custom text
+
+ ,
+ );
+ expect(screen.queryByText('Changesets')).not.toBeInTheDocument();
+ expect(screen.queryByText('Changesets in OSMCha')).not.toBeInTheDocument();
+ expect(screen.queryByText('Custom text')).toBeInTheDocument();
+ expect(container.querySelector('a').href).toBe(formatOSMChaLink(project));
+ expect(container.querySelector('svg.pl1')).not.toBeInTheDocument();
+ expect(container.querySelector('svg.pl2')).not.toBeInTheDocument();
+});
diff --git a/frontend/src/components/projectDetail/tests/permissionBox.test.js b/frontend/src/components/projectDetail/tests/permissionBox.test.js
deleted file mode 100644
index cdde5b8b00..0000000000
--- a/frontend/src/components/projectDetail/tests/permissionBox.test.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import { createComponentWithIntl } from '../../../utils/testWithIntl';
-import { PermissionBox } from '../permissionBox';
-
-describe('test if PermissionBox', () => {
- it('without validation returns correct style and strings', () => {
- const element = createComponentWithIntl(
);
- const testInstance = element.root;
-
- expect(testInstance.findByType('div').props.className).toBe('tc br1 f6 ba red');
- expect(testInstance.findByType(FormattedMessage).props.id).toBe('project.permissions.any');
- });
-
- it('without permission TEAMS returns correct style and strings', () => {
- const element = createComponentWithIntl(
-
,
- );
- const testInstance = element.root;
-
- expect(testInstance.findByType('div').props.className).toBe('tc br1 f6 ba orange');
- expect(testInstance.findByType(FormattedMessage).props.id).toBe('project.permissions.teams');
- });
-
- it('with validation and TEAMS permission returns correct style and strings', () => {
- const element = createComponentWithIntl(
-
,
- );
- const testInstance = element.root;
-
- expect(testInstance.findByType('div').props.className).toBe('tc br1 f6 ba red');
- expect(testInstance.findAllByType(FormattedMessage)[0].props.id).toBe(
- 'project.permissions.teams',
- );
- expect(testInstance.findAllByType(FormattedMessage)[1].props.id).toBe(
- 'project.detail.validation_team',
- );
- });
-
- it('with validation and TEAMS_LEVEL permission returns correct style and strings', () => {
- const element = createComponentWithIntl(
-
,
- );
- const testInstance = element.root;
-
- expect(testInstance.findByType('div').props.className).toBe('tc br1 f6 ba red');
- expect(testInstance.findAllByType(FormattedMessage)[0].props.id).toBe(
- 'project.permissions.teamsAndLevel',
- );
- expect(testInstance.findAllByType(FormattedMessage)[0].props.values.team).toBeTruthy();
- expect(testInstance.findAllByType(FormattedMessage)[1].props.id).toBe(
- 'project.detail.validation_team',
- );
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/permissionBox.test.jsx b/frontend/src/components/projectDetail/tests/permissionBox.test.jsx
new file mode 100644
index 0000000000..e6f74095a5
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/permissionBox.test.jsx
@@ -0,0 +1,63 @@
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { PermissionBox } from '../permissionBox';
+import { render, screen } from '@testing-library/react';
+import messages from '../messages';
+
+describe('test if PermissionBox', () => {
+ it('without validation returns correct style and strings', async () => {
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText(messages.permissions_ANY.defaultMessage)).toBeInTheDocument();
+ expect((await screen.findByText(messages.permissions_ANY.defaultMessage)).className).toEqual(
+ 'tc br1 f6 ba red',
+ );
+ });
+
+ it('without permission TEAMS returns correct style and strings', async () => {
+ render(
+
+
+ ,
+ );
+
+ // Can't use the message constant - it has to be parsed for this example
+ // Text is split into two parts
+ expect(await screen.findByText('Team')).toBeInTheDocument();
+ expect(await screen.findByText('members')).toBeInTheDocument();
+ expect((await screen.findByText('members')).className).toEqual('tc br1 f6 ba orange');
+ });
+
+ it('with validation and TEAMS permission returns correct style and strings', async () => {
+ render(
+
+
+ ,
+ );
+
+ // Can't use the message constant - it has to be parsed for this example
+ // Text is split into two parts
+ expect(await screen.findByText('Validation team')).toBeInTheDocument();
+ expect(await screen.findByText('members')).toBeInTheDocument();
+ expect((await screen.findByText('members')).className).toEqual('tc br1 f6 ba red');
+ });
+
+ it('with validation and TEAMS_LEVEL permission returns correct style and strings', async () => {
+ render(
+
+
+ ,
+ );
+
+ // Can't use the message constant - it has to be parsed for this example
+ // Text is split into two parts
+ expect(await screen.findByText('Intermediate and advanced members')).toBeInTheDocument();
+ expect(await screen.findByText('Validation team')).toBeInTheDocument();
+ expect((await screen.findByText('Intermediate and advanced members')).className).toEqual(
+ 'tc br1 f6 ba red',
+ );
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/privateProjectError.test.js b/frontend/src/components/projectDetail/tests/privateProjectError.test.js
deleted file mode 100644
index 25983c3493..0000000000
--- a/frontend/src/components/projectDetail/tests/privateProjectError.test.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import PrivateProjectError from '../privateProjectError';
-import {
- createComponentWithMemoryRouter,
- IntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-
-describe('PrivateProjectError component', () => {
- it('renders all items', () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(
- screen.getByRole('heading', { name: "You don't have permission to access this project" }),
- ).toBeInTheDocument();
- expect(
- screen.getByText(/please contact the project manager to request access\./i),
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', {
- name: /explore other projects/i,
- }),
- ).toBeEnabled();
- expect(container.querySelectorAll('svg').length).toBe(1);
- });
-
- it('should navigate to explore projects page', async () => {
- const { user, router } = createComponentWithMemoryRouter(
-
-
- ,
- );
-
- await user.click(
- screen.getByRole('button', {
- name: /explore other projects/i,
- }),
- );
- expect(router.state.location.pathname).toBe('/explore');
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/privateProjectError.test.jsx b/frontend/src/components/projectDetail/tests/privateProjectError.test.jsx
new file mode 100644
index 0000000000..87948f789d
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/privateProjectError.test.jsx
@@ -0,0 +1,46 @@
+
+import { screen } from '@testing-library/react';
+
+import PrivateProjectError from '../privateProjectError';
+import {
+ createComponentWithMemoryRouter,
+ IntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+
+describe('PrivateProjectError component', () => {
+ it('renders all items', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('heading', { name: "You don't have permission to access this project" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/please contact the project manager to request access\./i),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: /explore other projects/i,
+ }),
+ ).toBeEnabled();
+ expect(container.querySelectorAll('svg').length).toBe(1);
+ });
+
+ it('should navigate to explore projects page', async () => {
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', {
+ name: /explore other projects/i,
+ }),
+ );
+ expect(router.state.location.pathname).toBe('/explore');
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/questionsAndComments.test.js b/frontend/src/components/projectDetail/tests/questionsAndComments.test.js
deleted file mode 100644
index ba844a3cd9..0000000000
--- a/frontend/src/components/projectDetail/tests/questionsAndComments.test.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import '@testing-library/jest-dom';
-import userEvent from '@testing-library/user-event';
-import { render, screen, act, waitFor, within } from '@testing-library/react';
-
-import { store } from '../../../store';
-
-import {
- QueryClientProviders,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { getProjectSummary, projectComments } from '../../../network/tests/mockData/projects';
-import { QuestionsAndComments, PostProjectComment, CommentList } from '../questionsAndComments';
-// This is a late import in a React.lazy call; it takes awhile for commentInput to load
-import '../../comments/commentInput';
-
-describe('test if QuestionsAndComments component', () => {
- const project = getProjectSummary(1);
- it('only renders text asking user to log in for non-logged in user', () => {
- render(
-
-
-
-
- ,
- );
- expect(screen.getByText('Log in to be able to post comments.')).toBeInTheDocument();
- });
-
- it('renders tabs for writing and previewing comments', async () => {
- const user = userEvent.setup();
- render(
-
-
-
-
- ,
- );
- const previewBtn = await screen.findByRole('button', { name: /preview/i });
- expect(screen.getAllByRole('button').length).toBe(11);
- expect(screen.getByRole('button', { name: /write/i })).toBeInTheDocument();
- expect(screen.getAllByRole('button')).toHaveLength(11);
- expect(previewBtn).toBeInTheDocument();
- expect(screen.getByRole('textbox')).toBeInTheDocument();
- await user.click(previewBtn);
- expect(screen.queryByRole('textbox', { hidden: true })).toBeInTheDocument();
- expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
- });
-
- it('enables logged in user to post and view comments', async () => {
- act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: '123456', role: 'ADMIN' });
- });
- const { user } = renderWithRouter(
-
-
-
-
- ,
- );
- await waitFor(() => expect(screen.getByText('hello world')).toBeInTheDocument());
- const textarea = screen.getByRole('textbox');
- const postBtn = screen.getByRole('button', { name: /post/i });
- await user.type(textarea, 'Test comment');
- await user.click(postBtn);
- await waitFor(() => expect(screen.getByText(/Message sent./i)).toBeInTheDocument());
- expect(textarea).toHaveTextContent('');
- });
-
- it('should delete the comment', async () => {
- const retryFnMock = jest.fn();
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { role: 'ADMIN' },
- });
- const { user } = renderWithRouter(
-
-
- ,
- );
-
- await waitFor(() => expect(screen.getByText('hello world')).toBeInTheDocument());
- await user.click(screen.getAllByRole('button')[0]);
- await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }));
- await waitFor(() => expect(retryFnMock).toHaveBeenCalledTimes(1));
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/questionsAndComments.test.jsx b/frontend/src/components/projectDetail/tests/questionsAndComments.test.jsx
new file mode 100644
index 0000000000..c3f0e0a505
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/questionsAndComments.test.jsx
@@ -0,0 +1,93 @@
+
+import userEvent from '@testing-library/user-event';
+import { render, screen, act, waitFor, within } from '@testing-library/react';
+
+import { store } from '../../../store';
+
+import {
+ QueryClientProviders,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { getProjectSummary, projectComments } from '../../../network/tests/mockData/projects';
+import { QuestionsAndComments, PostProjectComment, CommentList } from '../questionsAndComments';
+// This is a late import in a React.lazy call; it takes awhile for commentInput to load
+import '../../comments/commentInput';
+
+describe('test if QuestionsAndComments component', () => {
+ const project = getProjectSummary(1);
+ it('only renders text asking user to log in for non-logged in user', () => {
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByText('Log in to be able to post comments.')).toBeInTheDocument();
+ });
+
+ it('renders tabs for writing and previewing comments', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+ ,
+ );
+ const previewBtn = await screen.findByRole('button', { name: /preview/i }, {
+ timeout: 5000,
+ })
+ expect(screen.getAllByRole('button').length).toBe(11);
+ waitFor(() => expect(screen.getByRole('button', { name: /write/i })));
+ expect(screen.getAllByRole('button')).toHaveLength(11);
+ waitFor(() => (screen.getByRole('textbox')));
+ await user.click(previewBtn);
+ expect(screen.queryByRole('textbox', { hidden: true })).toBeInTheDocument();
+ expect(await screen.findByText(/nothing to preview/i)).toBeInTheDocument();
+ });
+
+ it('enables logged in user to post and view comments', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: '123456', role: 'ADMIN' });
+ });
+ const { user } = renderWithRouter(
+
+
+
+
+ ,
+ );
+ expect(await screen.findByText('hello world')).toBeInTheDocument();
+ const textarea = screen.getByRole('textbox');
+ const postBtn = screen.getByRole('button', { name: /post/i });
+ await user.type(textarea, 'Test comment');
+ await user.click(postBtn);
+ await waitFor(() => expect(screen.getByText(/Message sent./i)).toBeInTheDocument());
+ expect(textarea).toHaveTextContent('');
+ });
+
+ it('should delete the comment', async () => {
+ const retryFnMock = vi.fn();
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { role: 'ADMIN' },
+ });
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('hello world')).toBeInTheDocument());
+ await user.click(screen.getAllByRole('button')[0]);
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }));
+ await waitFor(() => expect(retryFnMock).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/shareButton.test.js b/frontend/src/components/projectDetail/tests/shareButton.test.js
deleted file mode 100644
index 5c55cae7bb..0000000000
--- a/frontend/src/components/projectDetail/tests/shareButton.test.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-
-import { IntlProviders } from '../../../utils/testWithIntl';
-import { ShareButton } from '../shareButton';
-
-describe('test if shareButton', () => {
- it('render shareButton for project with id 1', async () => {
- const user = userEvent.setup();
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('Share')).toBeInTheDocument();
- await user.hover(screen.getByText('Share'));
- await waitFor(() => expect(screen.getByText('Tweet')).toBeInTheDocument());
- expect(screen.getByText('Post on Facebook')).toBeInTheDocument();
- expect(screen.getByText('Share on LinkedIn')).toBeInTheDocument();
-
- const svg = container.querySelectorAll('svg');
- expect(svg.length).toBe(4);
-
- const socialIconLabels = [];
- svg.forEach((icon) => {
- if (icon.attributes.getNamedItem('aria-label')) {
- socialIconLabels.push(icon.attributes.getNamedItem('aria-label').value);
- }
- });
- expect(socialIconLabels).toMatchObject(['Twitter', 'Facebook', 'LinkedIn']);
- });
-
- it('render open corresponding window popup', async () => {
- global.open = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- await user.hover(screen.getByText('Share'));
- await waitFor(() => expect(screen.getByText(/tweet/i)).toBeInTheDocument());
- await user.click(screen.getByText(/tweet/i));
- await user.click(screen.getByText(/post on facebook/i));
- await user.click(screen.getByText(/share on linkedin/i));
-
- expect(global.open).toHaveBeenCalledTimes(3);
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/shareButton.test.jsx b/frontend/src/components/projectDetail/tests/shareButton.test.jsx
new file mode 100644
index 0000000000..295e6f5bca
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/shareButton.test.jsx
@@ -0,0 +1,50 @@
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { ShareButton } from '../shareButton';
+
+describe('test if shareButton', () => {
+ it('render shareButton for project with id 1', async () => {
+ const user = userEvent.setup();
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('Share')).toBeInTheDocument();
+ await user.hover(screen.getByText('Share'));
+ await waitFor(() => expect(screen.getByText('Tweet')).toBeInTheDocument());
+ expect(screen.getByText('Post on Facebook')).toBeInTheDocument();
+ expect(screen.getByText('Share on LinkedIn')).toBeInTheDocument();
+
+ const svg = container.querySelectorAll('svg');
+ expect(svg.length).toBe(4);
+
+ const socialIconLabels = [];
+ svg.forEach((icon) => {
+ if (icon.attributes.getNamedItem('aria-label')) {
+ socialIconLabels.push(icon.attributes.getNamedItem('aria-label').value);
+ }
+ });
+ expect(socialIconLabels).toMatchObject(['Twitter', 'Facebook', 'LinkedIn']);
+ });
+
+ it('render open corresponding window popup', async () => {
+ global.open = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.hover(screen.getByText('Share'));
+ await waitFor(() => expect(screen.getByText(/tweet/i)).toBeInTheDocument());
+ await user.click(screen.getByText(/tweet/i));
+ await user.click(screen.getByText(/post on facebook/i));
+ await user.click(screen.getByText(/share on linkedin/i));
+
+ expect(global.open).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/similarProjects.test.js b/frontend/src/components/projectDetail/tests/similarProjects.test.js
deleted file mode 100644
index ffdc15c513..0000000000
--- a/frontend/src/components/projectDetail/tests/similarProjects.test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import { SimilarProjects } from '../similarProjects';
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { setupFaultyHandlers } from '../../../network/tests/server';
-import messages from '../messages';
-
-describe('Similar Projects', () => {
- it('should fetch and display similar projects', async () => {
- renderWithRouter(
-
-
- ,
- );
- expect((await screen.findAllByRole('article')).length).toBe(4);
- expect(
- screen.getByRole('heading', {
- name: 'Similar Project 1',
- }),
- ).toBeInTheDocument();
- });
-
- it('should display error message', async () => {
- setupFaultyHandlers();
- renderWithRouter(
-
-
- ,
- );
- expect(
- await screen.findByText(messages.noSimilarProjectsFound.defaultMessage),
- ).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/similarProjects.test.jsx b/frontend/src/components/projectDetail/tests/similarProjects.test.jsx
new file mode 100644
index 0000000000..3ee6832102
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/similarProjects.test.jsx
@@ -0,0 +1,35 @@
+
+import { screen } from '@testing-library/react';
+
+import { SimilarProjects } from '../similarProjects';
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { setupFaultyHandlers } from '../../../network/tests/server';
+import messages from '../messages';
+
+describe('Similar Projects', () => {
+ it('should fetch and display similar projects', async () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect((await screen.findAllByRole('article')).length).toBe(4);
+ expect(
+ screen.getByRole('heading', {
+ name: 'Similar Project 1',
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('should display error message', async () => {
+ setupFaultyHandlers();
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(
+ await screen.findByText(messages.noSimilarProjectsFound.defaultMessage),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/statusBox.test.js b/frontend/src/components/projectDetail/tests/statusBox.test.js
deleted file mode 100644
index 340baa6eec..0000000000
--- a/frontend/src/components/projectDetail/tests/statusBox.test.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ProjectStatusBox } from '../statusBox';
-import { IntlProviders } from '../../../utils/testWithIntl';
-
-describe('test if ProjectStatusBox component', () => {
- it('displays the DRAFT status as orange', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('Draft')).toBeInTheDocument();
- expect(screen.getByText('Draft').className).toContain('b--orange orange');
- expect(screen.getByText('Draft').className).not.toContain('b--blue-grey blue-grey');
- });
-
- it('displays ARCHIVED status as grey', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('Archived')).toBeInTheDocument();
- expect(screen.getByText('Archived').className).toContain('b--blue-grey blue-grey');
- expect(screen.getByText('Archived').className).not.toContain('b--orange orange');
- });
-});
diff --git a/frontend/src/components/projectDetail/tests/statusBox.test.jsx b/frontend/src/components/projectDetail/tests/statusBox.test.jsx
new file mode 100644
index 0000000000..2c1491bd34
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/statusBox.test.jsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ProjectStatusBox } from '../statusBox';
+import { IntlProviders } from '../../../utils/testWithIntl';
+
+describe('test if ProjectStatusBox component', () => {
+ it('displays the DRAFT status as orange', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Draft')).toBeInTheDocument();
+ expect(screen.getByText('Draft').className).toContain('b--orange orange');
+ expect(screen.getByText('Draft').className).not.toContain('b--blue-grey blue-grey');
+ });
+
+ it('displays ARCHIVED status as grey', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Archived')).toBeInTheDocument();
+ expect(screen.getByText('Archived').className).toContain('b--blue-grey blue-grey');
+ expect(screen.getByText('Archived').className).not.toContain('b--orange orange');
+ });
+});
diff --git a/frontend/src/components/projectDetail/tests/visibilityBox.test.js b/frontend/src/components/projectDetail/tests/visibilityBox.test.js
deleted file mode 100644
index 8d6ab9fc82..0000000000
--- a/frontend/src/components/projectDetail/tests/visibilityBox.test.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
-
-import { IntlProviders } from '../../../utils/testWithIntl';
-import { ProjectVisibilityBox } from '../visibilityBox';
-
-it('should display chip for private project', () => {
- render(
-
-
- ,
- );
- expect(screen.getByText(/private/i)).toBeInTheDocument();
- expect(screen.getByTitle('lock')).toBeInTheDocument();
-});
diff --git a/frontend/src/components/projectDetail/tests/visibilityBox.test.jsx b/frontend/src/components/projectDetail/tests/visibilityBox.test.jsx
new file mode 100644
index 0000000000..00eb64dcdf
--- /dev/null
+++ b/frontend/src/components/projectDetail/tests/visibilityBox.test.jsx
@@ -0,0 +1,15 @@
+
+import { render, screen } from '@testing-library/react';
+
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { ProjectVisibilityBox } from '../visibilityBox';
+
+it('should display chip for private project', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/private/i)).toBeInTheDocument();
+ expect(screen.getByTitle('lock')).toBeInTheDocument();
+});
diff --git a/frontend/src/components/projectDetail/timeline.js b/frontend/src/components/projectDetail/timeline.js
deleted file mode 100644
index c62efbb854..0000000000
--- a/frontend/src/components/projectDetail/timeline.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
- Chart as ChartJS,
- LineElement,
- PointElement,
- LinearScale,
- CategoryScale,
- TimeSeriesScale,
- Legend,
- Tooltip,
-} from 'chart.js';
-import { Line } from 'react-chartjs-2';
-import 'chartjs-adapter-date-fns';
-import { useIntl } from 'react-intl';
-
-import messages from './messages';
-import { formatTimelineData, formatTimelineTooltip } from '../../utils/formatChartJSData';
-import { CHART_COLOURS } from '../../config';
-import { useTimeDiff } from '../../hooks/UseTimeDiff';
-import { xAxisTimeSeries } from '../../utils/chart';
-
-ChartJS.register(
- LineElement,
- PointElement,
- LinearScale,
- CategoryScale,
- TimeSeriesScale,
- Legend,
- Tooltip,
-);
-
-export default function ProjectTimeline({ tasksByDay }: Object) {
- const intl = useIntl();
- const unit = useTimeDiff(tasksByDay);
- const mappedTasksConfig = {
- color: CHART_COLOURS.orange,
- label: intl.formatMessage(messages.mappedTasks),
- };
- const validatedTasksConfig = {
- color: CHART_COLOURS.red,
- label: intl.formatMessage(messages.validatedTasks),
- };
-
- return (
-
formatTimelineTooltip(context, true) },
- },
- },
- scales: {
- y: { ticks: { beginAtZero: true } },
- x: { ...xAxisTimeSeries(unit) },
- },
- }}
- />
- );
-}
diff --git a/frontend/src/components/projectDetail/timeline.tsx b/frontend/src/components/projectDetail/timeline.tsx
new file mode 100644
index 0000000000..c32b410247
--- /dev/null
+++ b/frontend/src/components/projectDetail/timeline.tsx
@@ -0,0 +1,64 @@
+import {
+ Chart as ChartJS,
+ LineElement,
+ PointElement,
+ LinearScale,
+ CategoryScale,
+ TimeSeriesScale,
+ Legend,
+ Tooltip,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+import 'chartjs-adapter-date-fns';
+import { useIntl } from 'react-intl';
+
+import messages from './messages';
+import { formatTimelineData, formatTimelineTooltip } from '../../utils/formatChartJSData';
+import { CHART_COLOURS } from '../../config';
+import { useTimeDiff } from '../../hooks/UseTimeDiff';
+import { xAxisTimeSeries } from '../../utils/chart';
+
+ChartJS.register(
+ LineElement,
+ PointElement,
+ LinearScale,
+ CategoryScale,
+ TimeSeriesScale,
+ Legend,
+ Tooltip,
+);
+
+export default function ProjectTimeline({ tasksByDay }: {
+ tasksByDay: {
+ date: string;
+ }[]
+}) {
+ const intl = useIntl();
+ const unit = useTimeDiff(tasksByDay);
+ const mappedTasksConfig = {
+ color: CHART_COLOURS.orange,
+ label: intl.formatMessage(messages.mappedTasks),
+ };
+ const validatedTasksConfig = {
+ color: CHART_COLOURS.red,
+ label: intl.formatMessage(messages.validatedTasks),
+ };
+
+ return (
+ formatTimelineTooltip(context, true) },
+ },
+ },
+ scales: {
+ y: { ticks: { beginAtZero: true } },
+ x: { ...xAxisTimeSeries(unit) },
+ },
+ }}
+ />
+ );
+}
diff --git a/frontend/src/components/projectDetail/visibilityBox.js b/frontend/src/components/projectDetail/visibilityBox.js
deleted file mode 100644
index c0bf422fe9..0000000000
--- a/frontend/src/components/projectDetail/visibilityBox.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import { LockIcon } from '../svgIcons';
-import messages from './messages';
-
-export const ProjectVisibilityBox = ({ className }: Object) => {
- return (
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectDetail/visibilityBox.jsx b/frontend/src/components/projectDetail/visibilityBox.jsx
new file mode 100644
index 0000000000..0e8e42b11d
--- /dev/null
+++ b/frontend/src/components/projectDetail/visibilityBox.jsx
@@ -0,0 +1,13 @@
+import { FormattedMessage } from 'react-intl';
+
+import { LockIcon } from '../svgIcons';
+import messages from './messages';
+
+export const ProjectVisibilityBox = ({ className }) => {
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectEdit/actionsForm.js b/frontend/src/components/projectEdit/actionsForm.js
deleted file mode 100644
index d9f1561027..0000000000
--- a/frontend/src/components/projectEdit/actionsForm.js
+++ /dev/null
@@ -1,687 +0,0 @@
-import { useState, useContext, useEffect, Suspense, lazy, forwardRef } from 'react';
-import { useSelector } from 'react-redux';
-import Popup from 'reactjs-popup';
-import Select from 'react-select';
-import { useNavigate } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { Button } from '../button';
-import { Alert } from '../alert';
-import { DeleteModal } from '../deleteModal';
-import { styleClasses, StateContext } from '../../views/projectEdit';
-import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
-import { useFetch } from '../../hooks/UseFetch';
-import { useAsync } from '../../hooks/UseAsync';
-import ReactPlaceholder from 'react-placeholder';
-const CommentInputField = lazy(() =>
- import('../comments/commentInput' /* webpackChunkName: "commentInput" */),
-);
-
-const ActionStatus = ({ status, action }) => {
- let successMessage = '';
- let errorMessage = '';
-
- switch (action) {
- case 'MESSAGE_CONTRIBUTORS':
- successMessage = 'messageContributorsSuccess';
- errorMessage = 'messageContributorsError';
- break;
- case 'MAP_ALL_TASKS':
- successMessage = 'mapAllSuccess';
- errorMessage = 'mapAllError';
- break;
- case 'INVALIDATE_ALL_TASKS':
- successMessage = 'invalidateAllSuccess';
- errorMessage = 'invalidateAllError';
- break;
- case 'VALIDATE_ALL_TASKS':
- successMessage = 'validateAllSuccess';
- errorMessage = 'validateAllError';
- break;
- case 'RESET_BAD_IMAGERY':
- successMessage = 'resetBadImagerySuccess';
- errorMessage = 'resetBadImageryError';
- break;
- case 'RESET_ALL':
- successMessage = 'resetAllSuccess';
- errorMessage = 'resetAllError';
- break;
- case 'REVERT_VALIDATED_TASKS':
- successMessage = 'revertVALIDATEDTasksSuccess';
- errorMessage = 'revertTasksError';
- break;
- case 'REVERT_BADIMAGERY_TASKS':
- successMessage = 'revertBADIMAGERYTasksSuccess';
- errorMessage = 'revertTasksError';
- break;
- case 'TRANSFER_PROJECT':
- successMessage = 'transferProjectSuccess';
- errorMessage = 'transferProjectError';
- break;
- case 'DELETE_PROJECT':
- successMessage = 'deleteProjectSuccess';
- errorMessage = 'deleteProjectError';
- break;
- default:
- return null;
- }
-
- return (
- <>
- {status === 'success' && (
-
-
-
- )}
- {status === 'error' && (
- { }
- )}
- >
- );
-};
-
-const ResetTasksModal = ({ projectId, close }: Object) => {
- const token = useSelector((state) => state.auth.token);
-
- const resetTasks = () => {
- return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/reset-all/`, token, 'POST');
- };
- const resetTasksAsync = useAsync(resetTasks);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- resetTasksAsync.execute()}
- loading={resetTasksAsync.status === 'pending'}
- disabled={resetTasksAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const ResetBadImageryModal = ({ projectId, close }: Object) => {
- const token = useSelector((state) => state.auth.token);
-
- const resetBadImagery = () => {
- return fetchLocalJSONAPI(
- `projects/${projectId}/tasks/actions/reset-all-badimagery/`,
- token,
- 'POST',
- );
- };
-
- const resetBadImageryAsync = useAsync(resetBadImagery);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- resetBadImageryAsync.execute()}
- loading={resetBadImageryAsync.status === 'pending'}
- disabled={resetBadImageryAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const ValidateAllTasksModal = ({ projectId, close }: Object) => {
- const token = useSelector((state) => state.auth.token);
-
- const validateAllTasks = () => {
- return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/validate-all/`, token, 'POST');
- };
-
- const validateAllTasksAsync = useAsync(validateAllTasks);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- validateAllTasksAsync.execute()}
- loading={validateAllTasksAsync.status === 'pending'}
- disabled={validateAllTasksAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const InvalidateAllTasksModal = ({ projectId, close }: Object) => {
- const token = useSelector((state) => state.auth.token);
-
- const invalidateAllTasks = () => {
- return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/invalidate-all/`, token, 'POST');
- };
-
- const invalidateAllTasksAsync = useAsync(invalidateAllTasks);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- invalidateAllTasksAsync.execute()}
- loading={invalidateAllTasksAsync.status === 'pending'}
- disabled={invalidateAllTasksAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const MapAllTasksModal = ({ projectId, close }: Object) => {
- const token = useSelector((state) => state.auth.token);
-
- const mapAllTasks = () => {
- return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/map-all/`, token, 'POST');
- };
- const mapAllTasksAsync = useAsync(mapAllTasks);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- mapAllTasksAsync.execute()}
- loading={mapAllTasksAsync.status === 'pending'}
- disabled={mapAllTasksAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const MessageContributorsModal = ({ projectId, close }: Object) => {
- const [subject, setSubject] = useState('');
- const [message, setMessage] = useState('');
- const token = useSelector((state) => state.auth.token);
-
- const messageAllContributors = () => {
- return pushToLocalJSONAPI(
- `projects/${projectId}/actions/message-contributors/`,
- JSON.stringify({
- subject: subject,
- message: message,
- }),
- token,
- 'POST',
- );
- };
-
- const messageAllContributorsAsync = useAsync(messageAllContributors);
-
- const handleSubjectChange = (e) => {
- setSubject(e.target.value);
- };
-
- return (
-
-
-
-
-
-
-
-
- {(msg) => {
- return (
-
- );
- }}
-
-
- {(msg) => {
- return (
-
- }
- >
-
-
-
- );
- }}
-
-
-
-
-
-
-
-
-
- messageAllContributorsAsync.execute()}
- loading={messageAllContributorsAsync.status === 'pending'}
- disabled={messageAllContributorsAsync.status === 'pending'}
- >
-
-
-
-
- );
-};
-
-const RevertTasks = ({ projectId, action }) => {
- const token = useSelector((state) => state.auth.token);
- const [user, setUser] = useState(null);
- const [, contributorsLoading, contributors] = useFetch(`projects/${projectId}/contributions/`);
-
- // To get the count of corresponding action key from contributors
- const actionKey = {
- VALIDATED: 'validated',
- BADIMAGERY: 'badImagery',
- };
-
- // List only contributors who have made corresponding {action}
- const curatedContributors = contributors.userContributions?.filter(
- (contributor) => contributor[actionKey[action]] > 0,
- );
-
- const handleUsernameSelection = (e) => {
- setUser(e);
- };
-
- const revertTasks = () => {
- return pushToLocalJSONAPI(
- `projects/${projectId}/tasks/actions/reset-by-user/?username=${user.username}&action=${action}`,
- null,
- token,
- 'POST',
- );
- };
-
- const revertTasksAsync = useAsync(revertTasks);
-
- return (
-
-
username}
- getOptionValue={({ username }) => username}
- onChange={handleUsernameSelection}
- value={user}
- options={curatedContributors}
- isLoading={contributorsLoading}
- />
- revertTasksAsync.execute()}
- loading={revertTasksAsync.status === 'pending'}
- disabled={revertTasksAsync.status === 'pending' || !user}
- className={styleClasses.buttonClass}
- >
-
-
-
-
- );
-};
-
-const TransferProject = ({ projectId, orgId }: Object) => {
- const token = useSelector((state) => state.auth.token);
- const { projectInfo } = useContext(StateContext);
- const [username, setUsername] = useState('');
- const [managers, setManagers] = useState([]);
- const [admins, setAdmins] = useState([]);
- const [isFetchingOptions, setIsFetchingOptions] = useState(true);
-
- useEffect(() => {
- fetchLocalJSONAPI(`organisations/${orgId}/?omitManagerList=false`, token)
- .then((r) => setManagers(r.managers.map((m) => m.username)))
- .then(() => setIsFetchingOptions(false));
-
- fetchLocalJSONAPI(`users/?pagination=false&role=ADMIN`, token).then((t) =>
- setAdmins(t.users.map((u) => u.username)),
- );
- }, [token, orgId]);
-
- const optionsExtended = [
- {
- label: projectInfo.organisationName,
- options: managers?.map((manager) => ({
- label: manager,
- value: manager,
- })),
- },
- {
- label: ,
- options: admins
- ?.filter((admin) => !managers?.includes(admin))
- .map((adminName) => ({
- label: adminName,
- value: adminName,
- })),
- },
- ];
-
- const handleSelect = (value) => {
- setUsername(value);
- };
- const { username: loggedInUsername, role: loggedInUserRole } = useSelector(
- (state) => state.auth.userDetails,
- );
- const hasAccess =
- managers?.includes(loggedInUsername) ||
- loggedInUserRole === 'ADMIN' ||
- loggedInUsername === projectInfo.author;
- const isDisabled = () => {
- return transferOwnershipAsync.status === 'pending' || !username || !hasAccess;
- };
- const transferOwnership = () => {
- return pushToLocalJSONAPI(
- `projects/${projectId}/actions/transfer-ownership/`,
- JSON.stringify({ username: username }),
- token,
- 'POST',
- );
- };
- const transferOwnershipAsync = useAsync(transferOwnership);
-
- return (
-
-
label}
- getOptionValue={({ value }) => value}
- onChange={(e) => handleSelect(e?.value)}
- value={optionsExtended?.find((manager) => manager.value === username)}
- options={optionsExtended}
- isLoading={isFetchingOptions}
- >
-
transferOwnershipAsync.execute()}
- loading={transferOwnershipAsync.status === 'pending'}
- disabled={isDisabled()}
- className={styleClasses.buttonClass}
- >
-
-
-
-
- );
-};
-
-const FormattedButtonTrigger = forwardRef((props, ref) => (
- {props.children}
-));
-
-export const ActionsForm = ({ projectId, projectName, orgId }: Object) => {
- const navigate = useNavigate();
-
- return (
-
-
-
-
-
-
-
-
- }
- modal
- closeOnDocumentClick
- nested
- >
- {(close) => }
-
-
-
-
-
-
-
-
-
-
-
- :{' '}
-
-
-
-
-
-
- }
- modal
- closeOnDocumentClick
- >
- {(close) => }
-
-
-
-
- }
- modal
- closeOnDocumentClick
- >
- {(close) => }
-
-
-
-
- }
- modal
- closeOnDocumentClick
- >
- {(close) => }
-
-
-
-
-
-
-
-
-
-
-
-
- :{' '}
-
-
-
-
-
-
- }
- modal
- closeOnDocumentClick
- >
- {(close) => }
-
-
-
-
- }
- modal
- closeOnDocumentClick
- >
- {(close) => }
-
-
-
- {['VALIDATED', 'BADIMAGERY'].map((action) => (
-
- ))}
-
-
-
-
-
-
-
- :{' '}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
navigate(`/manage/projects/new/?cloneFrom=${projectId}`)}
- className={styleClasses.actionClass}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- :{' '}
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectEdit/actionsForm.jsx b/frontend/src/components/projectEdit/actionsForm.jsx
new file mode 100644
index 0000000000..49a3545a4c
--- /dev/null
+++ b/frontend/src/components/projectEdit/actionsForm.jsx
@@ -0,0 +1,685 @@
+import { useState, useContext, useEffect, Suspense, lazy, forwardRef } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import Popup from 'reactjs-popup';
+import Select from 'react-select';
+import { useNavigate } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { Button } from '../button';
+import { Alert } from '../alert';
+import { DeleteModal } from '../deleteModal';
+import { styleClasses, StateContext } from '../../views/projectEdit';
+import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
+import { useFetch } from '../../hooks/UseFetch';
+import { useAsync } from '../../hooks/UseAsync';
+import ReactPlaceholder from 'react-placeholder';
+const CommentInputField = lazy(
+ () => import('../comments/commentInput' /* webpackChunkName: "commentInput" */),
+);
+
+const ActionStatus = ({ status, action }) => {
+ let successMessage = '';
+ let errorMessage = '';
+
+ switch (action) {
+ case 'MESSAGE_CONTRIBUTORS':
+ successMessage = 'messageContributorsSuccess';
+ errorMessage = 'messageContributorsError';
+ break;
+ case 'MAP_ALL_TASKS':
+ successMessage = 'mapAllSuccess';
+ errorMessage = 'mapAllError';
+ break;
+ case 'INVALIDATE_ALL_TASKS':
+ successMessage = 'invalidateAllSuccess';
+ errorMessage = 'invalidateAllError';
+ break;
+ case 'VALIDATE_ALL_TASKS':
+ successMessage = 'validateAllSuccess';
+ errorMessage = 'validateAllError';
+ break;
+ case 'RESET_BAD_IMAGERY':
+ successMessage = 'resetBadImagerySuccess';
+ errorMessage = 'resetBadImageryError';
+ break;
+ case 'RESET_ALL':
+ successMessage = 'resetAllSuccess';
+ errorMessage = 'resetAllError';
+ break;
+ case 'REVERT_VALIDATED_TASKS':
+ successMessage = 'revertVALIDATEDTasksSuccess';
+ errorMessage = 'revertTasksError';
+ break;
+ case 'REVERT_BADIMAGERY_TASKS':
+ successMessage = 'revertBADIMAGERYTasksSuccess';
+ errorMessage = 'revertTasksError';
+ break;
+ case 'TRANSFER_PROJECT':
+ successMessage = 'transferProjectSuccess';
+ errorMessage = 'transferProjectError';
+ break;
+ case 'DELETE_PROJECT':
+ successMessage = 'deleteProjectSuccess';
+ errorMessage = 'deleteProjectError';
+ break;
+ default:
+ return null;
+ }
+
+ return (
+ <>
+ {status === 'success' && (
+
+
+
+ )}
+ {status === 'error' && (
+ { }
+ )}
+ >
+ );
+};
+
+const ResetTasksModal = ({ projectId, close }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const resetTasks = () => {
+ return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/reset-all/`, token, 'POST');
+ };
+ const resetTasksAsync = useAsync(resetTasks);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ resetTasksAsync.execute()}
+ loading={resetTasksAsync.status === 'pending'}
+ disabled={resetTasksAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const ResetBadImageryModal = ({ projectId, close }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const resetBadImagery = () => {
+ return fetchLocalJSONAPI(
+ `projects/${projectId}/tasks/actions/reset-all-badimagery/`,
+ token,
+ 'POST',
+ );
+ };
+
+ const resetBadImageryAsync = useAsync(resetBadImagery);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ resetBadImageryAsync.execute()}
+ loading={resetBadImageryAsync.status === 'pending'}
+ disabled={resetBadImageryAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const ValidateAllTasksModal = ({ projectId, close }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const validateAllTasks = () => {
+ return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/validate-all/`, token, 'POST');
+ };
+
+ const validateAllTasksAsync = useAsync(validateAllTasks);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ validateAllTasksAsync.execute()}
+ loading={validateAllTasksAsync.status === 'pending'}
+ disabled={validateAllTasksAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const InvalidateAllTasksModal = ({ projectId, close }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const invalidateAllTasks = () => {
+ return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/invalidate-all/`, token, 'POST');
+ };
+
+ const invalidateAllTasksAsync = useAsync(invalidateAllTasks);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ invalidateAllTasksAsync.execute()}
+ loading={invalidateAllTasksAsync.status === 'pending'}
+ disabled={invalidateAllTasksAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const MapAllTasksModal = ({ projectId, close }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const mapAllTasks = () => {
+ return fetchLocalJSONAPI(`projects/${projectId}/tasks/actions/map-all/`, token, 'POST');
+ };
+ const mapAllTasksAsync = useAsync(mapAllTasks);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mapAllTasksAsync.execute()}
+ loading={mapAllTasksAsync.status === 'pending'}
+ disabled={mapAllTasksAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const MessageContributorsModal = ({ projectId, close }) => {
+ const [subject, setSubject] = useState('');
+ const [message, setMessage] = useState('');
+ const token = useTypedSelector((state) => state.auth.token);
+
+ const messageAllContributors = () => {
+ return pushToLocalJSONAPI(
+ `projects/${projectId}/actions/message-contributors/`,
+ JSON.stringify({
+ subject: subject,
+ message: message,
+ }),
+ token,
+ 'POST',
+ );
+ };
+
+ const messageAllContributorsAsync = useAsync(messageAllContributors);
+
+ const handleSubjectChange = (e) => {
+ setSubject(e.target.value);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {(msg) => {
+ return (
+
+ );
+ }}
+
+
+ {() => {
+ return (
+
+ }
+ >
+
+
+
+ );
+ }}
+
+
+
+
+
+
+
+
+
+ messageAllContributorsAsync.execute()}
+ loading={messageAllContributorsAsync.status === 'pending'}
+ disabled={messageAllContributorsAsync.status === 'pending'}
+ >
+
+
+
+
+ );
+};
+
+const RevertTasks = ({ projectId, action }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+ const [user, setUser] = useState(null);
+ const [, contributorsLoading, contributors] = useFetch(`projects/${projectId}/contributions/`);
+
+ // To get the count of corresponding action key from contributors
+ const actionKey = {
+ VALIDATED: 'validated',
+ BADIMAGERY: 'badImagery',
+ };
+
+ // List only contributors who have made corresponding {action}
+ const curatedContributors = contributors.userContributions?.filter(
+ (contributor) => contributor[actionKey[action]] > 0,
+ );
+
+ const handleUsernameSelection = (e) => {
+ setUser(e);
+ };
+
+ const revertTasks = () => {
+ return pushToLocalJSONAPI(
+ `projects/${projectId}/tasks/actions/reset-by-user/?username=${user.username}&action=${action}`,
+ null,
+ token,
+ 'POST',
+ );
+ };
+
+ const revertTasksAsync = useAsync(revertTasks);
+
+ return (
+
+
username}
+ getOptionValue={({ username }) => username}
+ onChange={handleUsernameSelection}
+ value={user}
+ options={curatedContributors}
+ isLoading={contributorsLoading}
+ />
+ revertTasksAsync.execute()}
+ loading={revertTasksAsync.status === 'pending'}
+ disabled={revertTasksAsync.status === 'pending' || !user}
+ className={styleClasses.buttonClass}
+ >
+
+
+
+
+ );
+};
+
+const TransferProject = ({ projectId, orgId }) => {
+ const token = useTypedSelector((state) => state.auth.token);
+ const { projectInfo } = useContext(StateContext);
+ const [username, setUsername] = useState('');
+ const [managers, setManagers] = useState([]);
+ const [admins, setAdmins] = useState([]);
+ const [isFetchingOptions, setIsFetchingOptions] = useState(true);
+
+ useEffect(() => {
+ fetchLocalJSONAPI(`organisations/${orgId}/?omitManagerList=false`, token)
+ .then((r) => setManagers(r.managers.map((m) => m.username)))
+ .then(() => setIsFetchingOptions(false));
+
+ fetchLocalJSONAPI(`users/?pagination=false&role=ADMIN`, token).then((t) =>
+ setAdmins(t.users.map((u) => u.username)),
+ );
+ }, [token, orgId]);
+
+ const optionsExtended = [
+ {
+ label: projectInfo.organisationName,
+ options: managers?.map((manager) => ({
+ label: manager,
+ value: manager,
+ })),
+ },
+ {
+ label: ,
+ options: admins
+ ?.filter((admin) => !managers?.includes(admin))
+ .map((adminName) => ({
+ label: adminName,
+ value: adminName,
+ })),
+ },
+ ];
+
+ const handleSelect = (value) => {
+ setUsername(value);
+ };
+ const { username: loggedInUsername, role: loggedInUserRole } = useTypedSelector(
+ (state) => state.auth.userDetails,
+ );
+ const hasAccess =
+ managers?.includes(loggedInUsername) ||
+ loggedInUserRole === 'ADMIN' ||
+ loggedInUsername === projectInfo.author;
+ const isDisabled = () => {
+ return transferOwnershipAsync.status === 'pending' || !username || !hasAccess;
+ };
+ const transferOwnership = () => {
+ return pushToLocalJSONAPI(
+ `projects/${projectId}/actions/transfer-ownership/`,
+ JSON.stringify({ username: username }),
+ token,
+ 'POST',
+ );
+ };
+ const transferOwnershipAsync = useAsync(transferOwnership);
+
+ return (
+
+
label}
+ getOptionValue={({ value }) => value}
+ onChange={(e) => handleSelect(e?.value)}
+ value={optionsExtended?.find((manager) => manager.value === username)}
+ options={optionsExtended}
+ isLoading={isFetchingOptions}
+ >
+
transferOwnershipAsync.execute()}
+ loading={transferOwnershipAsync.status === 'pending'}
+ disabled={isDisabled()}
+ className={styleClasses.buttonClass}
+ >
+
+
+
+
+ );
+};
+
+const FormattedButtonTrigger = forwardRef((props) => {props.children} );
+
+export const ActionsForm = ({ projectId, projectName, orgId }) => {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ nested
+ >
+ {(close) => }
+
+
+
+
+
+
+
+
+
+
+
+ :{' '}
+
+
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ >
+ {(close) => }
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ >
+ {(close) => }
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ >
+ {(close) => }
+
+
+
+
+
+
+
+
+
+
+
+
+ :{' '}
+
+
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ >
+ {(close) => }
+
+
+
+
+ }
+ modal
+ closeOnDocumentClick
+ >
+ {(close) => }
+
+
+
+ {['VALIDATED', 'BADIMAGERY'].map((action) => (
+
+ ))}
+
+
+
+
+
+
+
+ :{' '}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
navigate(`/manage/projects/new/?cloneFrom=${projectId}`)}
+ className={styleClasses.actionClass}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :{' '}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectEdit/customEditorForm.js b/frontend/src/components/projectEdit/customEditorForm.js
deleted file mode 100644
index f439a2f5df..0000000000
--- a/frontend/src/components/projectEdit/customEditorForm.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useContext } from 'react';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { SwitchToggle } from '../formInputs';
-import { StateContext, styleClasses } from '../../views/projectEdit';
-import { CustomButton } from '../button';
-import { WasteIcon } from '../svgIcons';
-
-const CustomEditorTextInput = ({ name, value, handleChange }) => {
- return (
-
-
- {name === 'name' ? (
-
- ) : (
-
- )}
-
-
-
- );
-};
-
-export const CustomEditorForm = ({ languages }) => {
- const { projectInfo, setProjectInfo } = useContext(StateContext);
-
- const handleChange = (event) => {
- var value = (val) =>
- event.target.type === 'checkbox' ? event.target.checked : event.target.value;
- var customEditor = { ...projectInfo.customEditor, [event.target.name]: value() };
- setProjectInfo({ ...projectInfo, customEditor: customEditor });
- };
-
- const handleMappingEditors = () => {
- let editors = projectInfo.mappingEditors;
- if (editors.includes('CUSTOM')) {
- editors = editors.filter((item) => item !== 'CUSTOM');
- } else {
- editors.push('CUSTOM');
- }
- setProjectInfo({ ...projectInfo, mappingEditors: editors });
- };
-
- const handleValidationEditors = () => {
- let editors = projectInfo.validationEditors;
- if (editors.includes('CUSTOM')) {
- editors = editors.filter((item) => item !== 'CUSTOM');
- } else {
- editors.push('CUSTOM');
- }
- setProjectInfo({ ...projectInfo, validationEditors: editors });
- };
-
- const handleRemove = (event) => {
- setProjectInfo({ ...projectInfo, customEditor: null });
- };
-
- return (
-
-
-
-
-
-
-
-
-
- {projectInfo.customEditor && (
- <>
-
-
- }
- labelPosition="right"
- isChecked={projectInfo.mappingEditors.includes('CUSTOM')}
- onChange={handleMappingEditors}
- />
-
-
- }
- labelPosition="right"
- isChecked={projectInfo.validationEditors.includes('CUSTOM')}
- onChange={handleValidationEditors}
- />
-
-
-
- >
- )}
-
- );
-};
diff --git a/frontend/src/components/projectEdit/customEditorForm.jsx b/frontend/src/components/projectEdit/customEditorForm.jsx
new file mode 100644
index 0000000000..3b6475d080
--- /dev/null
+++ b/frontend/src/components/projectEdit/customEditorForm.jsx
@@ -0,0 +1,126 @@
+import { useContext } from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { SwitchToggle } from '../formInputs';
+import { StateContext, styleClasses } from '../../views/projectEdit';
+import { CustomButton } from '../button';
+import { WasteIcon } from '../svgIcons';
+
+const CustomEditorTextInput = ({ name, value, handleChange }) => {
+ return (
+
+
+ {name === 'name' ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export const CustomEditorForm = () => {
+ const { projectInfo, setProjectInfo } = useContext(StateContext);
+
+ const handleChange = (event) => {
+ var value = () =>
+ event.target.type === 'checkbox' ? event.target.checked : event.target.value;
+ var customEditor = { ...projectInfo.customEditor, [event.target.name]: value() };
+ setProjectInfo({ ...projectInfo, customEditor: customEditor });
+ };
+
+ const handleMappingEditors = () => {
+ let editors = projectInfo.mappingEditors;
+ if (editors.includes('CUSTOM')) {
+ editors = editors.filter((item) => item !== 'CUSTOM');
+ } else {
+ editors.push('CUSTOM');
+ }
+ setProjectInfo({ ...projectInfo, mappingEditors: editors });
+ };
+
+ const handleValidationEditors = () => {
+ let editors = projectInfo.validationEditors;
+ if (editors.includes('CUSTOM')) {
+ editors = editors.filter((item) => item !== 'CUSTOM');
+ } else {
+ editors.push('CUSTOM');
+ }
+ setProjectInfo({ ...projectInfo, validationEditors: editors });
+ };
+
+ const handleRemove = () => {
+ setProjectInfo({ ...projectInfo, customEditor: null });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {projectInfo.customEditor && (
+ <>
+
+
+ }
+ labelPosition="right"
+ isChecked={projectInfo.mappingEditors.includes('CUSTOM')}
+ onChange={handleMappingEditors}
+ />
+
+
+ }
+ labelPosition="right"
+ isChecked={projectInfo.validationEditors.includes('CUSTOM')}
+ onChange={handleValidationEditors}
+ />
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/components/projectEdit/descriptionForm.js b/frontend/src/components/projectEdit/descriptionForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/descriptionForm.js
rename to frontend/src/components/projectEdit/descriptionForm.jsx
diff --git a/frontend/src/components/projectEdit/extraIdParams.js b/frontend/src/components/projectEdit/extraIdParams.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/extraIdParams.js
rename to frontend/src/components/projectEdit/extraIdParams.jsx
diff --git a/frontend/src/components/projectEdit/imageryForm.js b/frontend/src/components/projectEdit/imageryForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/imageryForm.js
rename to frontend/src/components/projectEdit/imageryForm.jsx
diff --git a/frontend/src/components/projectEdit/inputLocale.js b/frontend/src/components/projectEdit/inputLocale.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/inputLocale.js
rename to frontend/src/components/projectEdit/inputLocale.jsx
diff --git a/frontend/src/components/projectEdit/instructionsForm.js b/frontend/src/components/projectEdit/instructionsForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/instructionsForm.js
rename to frontend/src/components/projectEdit/instructionsForm.jsx
diff --git a/frontend/src/components/projectEdit/localeOption.js b/frontend/src/components/projectEdit/localeOption.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/localeOption.js
rename to frontend/src/components/projectEdit/localeOption.jsx
diff --git a/frontend/src/components/projectEdit/messages.js b/frontend/src/components/projectEdit/messages.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/messages.js
rename to frontend/src/components/projectEdit/messages.jsx
diff --git a/frontend/src/components/projectEdit/metadataForm.js b/frontend/src/components/projectEdit/metadataForm.js
deleted file mode 100644
index 707f0e65ed..0000000000
--- a/frontend/src/components/projectEdit/metadataForm.js
+++ /dev/null
@@ -1,259 +0,0 @@
-import { useContext, useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
-import Select from 'react-select';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import typesMessages from '../messages';
-import { StateContext, styleClasses } from '../../views/projectEdit';
-import { CheckBox } from '../formInputs';
-import { ProjectInterests } from './projectInterests';
-import { ExtraIdParams } from './extraIdParams';
-import { Code } from '../code';
-import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
-import { ID_PRESETS } from '../../config/presets';
-import { getFilterId } from '../../utils/osmchaLink';
-
-export const MetadataForm = () => {
- const { projectInfo, setProjectInfo } = useContext(StateContext);
- const [interests, setInterests] = useState([]);
- const userDetails = useSelector((state) => state.auth.userDetails);
- const token = useSelector((state) => state.auth.token);
- const [organisations, setOrganisations] = useState([]);
- const [campaigns, setCampaigns] = useState([]);
-
- useEffect(() => {
- if (userDetails && userDetails.id) {
- const query = userDetails.role === 'ADMIN' ? '' : `&manager_user_id=${userDetails.id}`;
- fetchLocalJSONAPI(`organisations/?omitManagerList=true${query}`, token)
- .then((result) => setOrganisations(result.organisations))
- .catch((e) => console.log(e));
- }
-
- fetchLocalJSONAPI('campaigns/')
- .then((res) => setCampaigns(res.campaigns))
- .catch((e) => console.log(e));
- }, [userDetails, token]);
-
- const elements = [
- { item: 'ROADS', messageId: 'roads' },
- { item: 'BUILDINGS', messageId: 'buildings' },
- { item: 'WATERWAYS', messageId: 'waterways' },
- { item: 'LAND_USE', messageId: 'landUse' },
- { item: 'OTHER', messageId: 'other' },
- ];
-
- const handleMappingTypes = (types) => {
- setProjectInfo({ ...projectInfo, mappingTypes: types });
- };
-
- useEffect(() => {
- if (interests.length === 0) {
- fetchLocalJSONAPI('interests/').then((res) => {
- setInterests(res.interests);
- });
- }
- }, [interests.length]);
-
- // Get id presets members:
- let idPresetsValue = [];
- const presets = Object.keys(ID_PRESETS).map((p) => {
- const categoryLabel = ID_PRESETS[p].name;
-
- const opts = ID_PRESETS[p].members.map((l) => {
- const obj = { label: l, value: l };
-
- if (projectInfo.idPresets && projectInfo.idPresets.includes(l) === true) {
- idPresetsValue.push(obj);
- }
- return obj;
- });
-
- return { label: categoryLabel, options: opts };
- });
-
- return (
-
-
-
-
-
-
-
-
- {['EASY', 'MODERATE', 'CHALLENGING'].map((level) => (
-
-
- setProjectInfo({
- ...projectInfo,
- difficulty: level,
- })
- }
- type="radio"
- className={`radio-input input-reset pointer v-mid dib h2 w2 mr2 br-100 ba b--blue-light`}
- />
-
-
- ))}
-
-
-
-
- *
-
- {elements.map((elm) => (
-
- ))}
-
-
-
-
-
- {
- if (val === null) {
- setProjectInfo((p) => {
- return { ...p, idPresets: [] };
- });
-
- return;
- }
- const values = val.map((v) => v.value);
- setProjectInfo((p) => {
- return { ...p, idPresets: values };
- });
- }}
- defaultValue={idPresetsValue}
- />
-
-
-
-
-
-
-
- disabled_features=buildings&offset=-10,5 }}
- />
-
-
- }}
- />
-
-
-
-
-
- *
-
-
-
-
-
option.name}
- getOptionValue={(option) => option.organisationId}
- options={organisations}
- defaultValue={
- projectInfo.organisation && {
- name: projectInfo.organisationName,
- value: projectInfo.organisation,
- }
- }
- placeholder={ }
- onChange={(value) =>
- setProjectInfo({ ...projectInfo, organisation: value.organisationId || '' })
- }
- className="z-5"
- />
-
-
-
-
-
- option.name}
- getOptionValue={(option) => option.id}
- isMulti={true}
- options={campaigns}
- placeholder={ }
- className="z-4"
- defaultValue={projectInfo.campaigns}
- isSearchable={true}
- onChange={(value) => setProjectInfo({ ...projectInfo, campaigns: value })}
- />
-
-
-
-
-
-
-
-
-
-
-
{
- setProjectInfo({
- ...projectInfo,
- osmchaFilterId: getFilterId(e.target.value),
- });
- }}
- />
-
-
- );
-};
-
-const IdDocsLink = () => (
-
-
-
-);
diff --git a/frontend/src/components/projectEdit/metadataForm.jsx b/frontend/src/components/projectEdit/metadataForm.jsx
new file mode 100644
index 0000000000..28c8587c34
--- /dev/null
+++ b/frontend/src/components/projectEdit/metadataForm.jsx
@@ -0,0 +1,259 @@
+import { useContext, useEffect, useState } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import Select from 'react-select';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import typesMessages from '../messages';
+import { StateContext, styleClasses } from '../../views/projectEdit';
+import { CheckBox } from '../formInputs';
+import { ProjectInterests } from './projectInterests';
+import { ExtraIdParams } from './extraIdParams';
+import { Code } from '../code';
+import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
+import { ID_PRESETS } from '../../config/presets';
+import { getFilterId } from '../../utils/osmchaLink';
+
+export const MetadataForm = () => {
+ const { projectInfo, setProjectInfo } = useContext(StateContext);
+ const [interests, setInterests] = useState([]);
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const token = useTypedSelector((state) => state.auth.token);
+ const [organisations, setOrganisations] = useState([]);
+ const [campaigns, setCampaigns] = useState([]);
+
+ useEffect(() => {
+ if (userDetails && userDetails?.id) {
+ const query = userDetails?.role === 'ADMIN' ? '' : `&manager_user_id=${userDetails?.id}`;
+ fetchLocalJSONAPI(`organisations/?omitManagerList=true${query}`, token)
+ .then((result) => setOrganisations(result.organisations))
+ .catch((e) => console.log(e));
+ }
+
+ fetchLocalJSONAPI('campaigns/')
+ .then((res) => setCampaigns(res.campaigns))
+ .catch((e) => console.log(e));
+ }, [userDetails, token]);
+
+ const elements = [
+ { item: 'ROADS', messageId: 'roads' },
+ { item: 'BUILDINGS', messageId: 'buildings' },
+ { item: 'WATERWAYS', messageId: 'waterways' },
+ { item: 'LAND_USE', messageId: 'landUse' },
+ { item: 'OTHER', messageId: 'other' },
+ ];
+
+ const handleMappingTypes = (types) => {
+ setProjectInfo({ ...projectInfo, mappingTypes: types });
+ };
+
+ useEffect(() => {
+ if (interests.length === 0) {
+ fetchLocalJSONAPI('interests/').then((res) => {
+ setInterests(res.interests);
+ });
+ }
+ }, [interests.length]);
+
+ // Get id presets members:
+ let idPresetsValue = [];
+ const presets = Object.keys(ID_PRESETS).map((p) => {
+ const categoryLabel = ID_PRESETS[p].name;
+
+ const opts = ID_PRESETS[p].members.map((l) => {
+ const obj = { label: l, value: l };
+
+ if (projectInfo.idPresets && projectInfo.idPresets.includes(l) === true) {
+ idPresetsValue.push(obj);
+ }
+ return obj;
+ });
+
+ return { label: categoryLabel, options: opts };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ {['EASY', 'MODERATE', 'CHALLENGING'].map((level) => (
+
+
+ setProjectInfo({
+ ...projectInfo,
+ difficulty: level,
+ })
+ }
+ type="radio"
+ className={`radio-input input-reset pointer v-mid dib h2 w2 mr2 br-100 ba b--blue-light`}
+ />
+
+
+ ))}
+
+
+
+
+ *
+
+ {elements.map((elm) => (
+
+ ))}
+
+
+
+
+
+ {
+ if (val === null) {
+ setProjectInfo((p) => {
+ return { ...p, idPresets: [] };
+ });
+
+ return;
+ }
+ const values = val.map((v) => v.value);
+ setProjectInfo((p) => {
+ return { ...p, idPresets: values };
+ });
+ }}
+ defaultValue={idPresetsValue}
+ />
+
+
+
+
+
+
+
+ disabled_features=buildings&offset=-10,5 }}
+ />
+
+
+ }}
+ />
+
+
+
+
+
+ *
+
+
+
+
+
option.name}
+ getOptionValue={(option) => option.organisationId}
+ options={organisations}
+ defaultValue={
+ projectInfo.organisation && {
+ name: projectInfo.organisationName,
+ value: projectInfo.organisation,
+ }
+ }
+ placeholder={ }
+ onChange={(value) =>
+ setProjectInfo({ ...projectInfo, organisation: value.organisationId || '' })
+ }
+ className="z-5"
+ />
+
+
+
+
+
+ option.name}
+ getOptionValue={(option) => option.id}
+ isMulti={true}
+ options={campaigns}
+ placeholder={ }
+ className="z-4"
+ defaultValue={projectInfo.campaigns}
+ isSearchable={true}
+ onChange={(value) => setProjectInfo({ ...projectInfo, campaigns: value })}
+ />
+
+
+
+
+
+
+
+
+
+
+
{
+ setProjectInfo({
+ ...projectInfo,
+ osmchaFilterId: getFilterId(e.target.value),
+ });
+ }}
+ />
+
+
+ );
+};
+
+const IdDocsLink = () => (
+
+
+
+);
diff --git a/frontend/src/components/projectEdit/partnersForm.js b/frontend/src/components/projectEdit/partnersForm.js
deleted file mode 100644
index a938520e5b..0000000000
--- a/frontend/src/components/projectEdit/partnersForm.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import { useEffect, useState, forwardRef, useMemo } from 'react';
-import { useParams } from 'react-router-dom';
-import { useSelector } from 'react-redux';
-import Select from 'react-select';
-import ReactDatePicker from 'react-datepicker';
-import { FormattedMessage } from 'react-intl';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import { format } from 'date-fns';
-import toast from 'react-hot-toast';
-import PropTypes from 'prop-types';
-
-import messages from './messages';
-import { Alert } from '../alert';
-import { ChevronDownIcon, CloseIcon } from '../svgIcons';
-import { Button } from '../button';
-import { styleClasses } from '../../views/projectEdit';
-import { pushToLocalJSONAPI } from '../../network/genericJSONRequest';
-import { useAllPartnersQuery } from '../../api/projects';
-import { Listing } from './partnersListing';
-
-export const DateCustomInput = forwardRef(
- (
- {
- value,
- onClick,
- date,
- handleClear,
- isStartDate = true,
- hideCloseIcon = false,
- inputStyles = {},
- placeholderMessage = {},
- },
- ref,
- ) => {
- let placeholder = messages[isStartDate ? 'partnerStartDate' : 'partnerEndDate'];
- if (placeholderMessage.id) {
- placeholder = placeholderMessage
- }
-
- return (
-
-
- {(message) => {
- return (
-
- );
- }}
-
-
- {((date && hideCloseIcon) || !date) && (
-
-
-
- )}
-
- {date && !hideCloseIcon && (
-
-
-
- )}
-
- );
- },
-);
-
-DateCustomInput.propTypes = {
- value: PropTypes.string,
- date: PropTypes.instanceOf(Date),
- onClick: PropTypes.func.isRequired,
- handleClear: PropTypes.func,
- isStartDate: PropTypes.bool,
- hideCloseIcon: PropTypes.bool,
- inputStyles: PropTypes.object,
- placeholderMessage: PropTypes.object
-};
-
-export const PartnersForm = () => {
- const [selectedPartner, setSelectedPartner] = useState({});
- const [dateRange, setDateRange] = useState({
- startDate: new Date(),
- endDate: null,
- });
- const [errorMessage, setErrorMessage] = useState({});
- const userDetails = useSelector((state) => state.auth.userDetails);
- const token = useSelector((state) => state.auth.token);
- const queryClient = useQueryClient();
- const { id } = useParams();
-
- // clear partnerNotSelectedError message when partner gets selected
- useEffect(() => {
- if (
- selectedPartner &&
- errorMessage.id &&
- errorMessage.id === messages.partnerNotSelectedError.id
- ) {
- setErrorMessage({});
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedPartner]);
-
- // clear dateRange error messages when the right dates are picked
- useEffect(() => {
- if (!dateRange.endDate && errorMessage.id === messages.partnerEndDateError.id) {
- setErrorMessage({});
- return;
- }
-
- // clear error message if present when the selected endDate is after startDate
- if (
- dateRange.endDate &&
- dateRange.startDate < dateRange.endDate &&
- errorMessage.id === messages.partnerEndDateError.id
- ) {
- setErrorMessage({});
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dateRange]);
-
- const { isPending, isError, data: partners } = useAllPartnersQuery(token, userDetails.id);
-
- const savePartnerMutation = useMutation({
- mutationFn: () => {
- const startDate = `${format(dateRange.startDate, 'yyyy-MM-dd')}T00:00:00.000Z`;
- const endDate = dateRange.endDate
- ? `${format(dateRange.endDate, 'yyyy-MM-dd')}T00:00:00.000Z`
- : null;
-
- return pushToLocalJSONAPI(
- `projects/partnerships/`,
- JSON.stringify({
- endedOn: endDate,
- partnerId: selectedPartner.id,
- projectId: id,
- startedOn: startDate,
- }),
- token,
- 'POST',
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
- setDateRange({
- startDate: new Date(),
- endDate: null,
- });
- setSelectedPartner({});
- toast.success( );
- },
- onError: () => {
- toast.error( );
- },
- });
-
- const partnerIdToDetailsMapping = useMemo(() => {
- const mapping = {};
- for (let i = 0; i < partners?.length; i++) {
- mapping[partners[i].id] = partners[i];
- }
- return mapping;
- }, [partners]);
-
- const handleSave = () => {
- if (!selectedPartner?.id) {
- setErrorMessage(messages.partnerNotSelectedError);
- return;
- }
-
- if (dateRange.endDate && dateRange.startDate > dateRange.endDate) {
- setErrorMessage(messages.partnerEndDateError);
- return;
- }
-
- savePartnerMutation.mutate();
- };
-
- return (
-
-
-
-
-
-
-
-
-
option.name}
- getOptionValue={(option) => option.id}
- options={partners}
- value={selectedPartner.id ? selectedPartner : null}
- placeholder={
- isError ? (
-
- ) : (
-
- )
- }
- onChange={(value) => (value ? setSelectedPartner(value) : setSelectedPartner({}))}
- />
-
-
-
-
- setDateRange({
- ...dateRange,
- startDate: date,
- })
- }
- dateFormat="dd/MM/yyyy"
- showYearDropdown
- scrollableYearDropdown
- customInput={
-
- }
- />
-
-
-
-
-
-
-
- setDateRange({
- ...dateRange,
- endDate: date,
- })
- }
- dateFormat="dd/MM/yyyy"
- showYearDropdown
- scrollableYearDropdown
- customInput={
- {
- setDateRange({
- ...dateRange,
- endDate: null,
- });
- }}
- isStartDate={false}
- inputStyles={{ maxWidth: '9rem' }}
- />
- }
- />
-
-
-
-
-
-
-
-
-
-
- {errorMessage.id ? (
-
- ) : null}
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectEdit/partnersForm.jsx b/frontend/src/components/projectEdit/partnersForm.jsx
new file mode 100644
index 0000000000..9f18bb7b3a
--- /dev/null
+++ b/frontend/src/components/projectEdit/partnersForm.jsx
@@ -0,0 +1,300 @@
+import { useEffect, useState, forwardRef, useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+import { useTypedSelector } from '@Store/hooks';
+import Select from 'react-select';
+import ReactDatePicker from 'react-datepicker';
+import { FormattedMessage } from 'react-intl';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import toast from 'react-hot-toast';
+import PropTypes from 'prop-types';
+
+import messages from './messages';
+import { Alert } from '../alert';
+import { ChevronDownIcon, CloseIcon } from '../svgIcons';
+import { Button } from '../button';
+import { styleClasses } from '../../views/projectEdit';
+import { pushToLocalJSONAPI } from '../../network/genericJSONRequest';
+import { useAllPartnersQuery } from '../../api/projects';
+import { Listing } from './partnersListing';
+
+export const DateCustomInput = forwardRef(
+ (
+ {
+ value,
+ onClick,
+ date,
+ handleClear,
+ isStartDate = true,
+ hideCloseIcon = false,
+ inputStyles = {},
+ placeholderMessage = {},
+ },
+ ref,
+ ) => {
+ let placeholder = messages[isStartDate ? 'partnerStartDate' : 'partnerEndDate'];
+ if (placeholderMessage.id) {
+ placeholder = placeholderMessage
+ }
+
+ return (
+
+
+ {(message) => {
+ return (
+
+ );
+ }}
+
+
+ {((date && hideCloseIcon) || !date) && (
+
+
+
+ )}
+
+ {date && !hideCloseIcon && (
+
+
+
+ )}
+
+ );
+ },
+);
+
+DateCustomInput.propTypes = {
+ value: PropTypes.string,
+ date: PropTypes.instanceOf(Date),
+ onClick: PropTypes.func.isRequired,
+ handleClear: PropTypes.func,
+ isStartDate: PropTypes.bool,
+ hideCloseIcon: PropTypes.bool,
+ inputStyles: PropTypes.object,
+ placeholderMessage: PropTypes.object
+};
+
+export const PartnersForm = () => {
+ const [selectedPartner, setSelectedPartner] = useState({});
+ const [dateRange, setDateRange] = useState({
+ startDate: new Date(),
+ endDate: null,
+ });
+ const [errorMessage, setErrorMessage] = useState({});
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const token = useTypedSelector((state) => state.auth.token);
+ const queryClient = useQueryClient();
+ const { id } = useParams();
+
+ // clear partnerNotSelectedError message when partner gets selected
+ useEffect(() => {
+ if (
+ selectedPartner &&
+ errorMessage.id &&
+ errorMessage.id === messages.partnerNotSelectedError.id
+ ) {
+ setErrorMessage({});
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedPartner]);
+
+ // clear dateRange error messages when the right dates are picked
+ useEffect(() => {
+ if (!dateRange.endDate && errorMessage.id === messages.partnerEndDateError.id) {
+ setErrorMessage({});
+ return;
+ }
+
+ // clear error message if present when the selected endDate is after startDate
+ if (
+ dateRange.endDate &&
+ dateRange.startDate < dateRange.endDate &&
+ errorMessage.id === messages.partnerEndDateError.id
+ ) {
+ setErrorMessage({});
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dateRange]);
+
+ const { isPending, isError, data: partners } = useAllPartnersQuery(token, userDetails?.id);
+
+ const savePartnerMutation = useMutation({
+ mutationFn: () => {
+ const startDate = `${format(dateRange.startDate, 'yyyy-MM-dd')}T00:00:00.000Z`;
+ const endDate = dateRange.endDate
+ ? `${format(dateRange.endDate, 'yyyy-MM-dd')}T00:00:00.000Z`
+ : null;
+
+ return pushToLocalJSONAPI(
+ `projects/partnerships/`,
+ JSON.stringify({
+ endedOn: endDate,
+ partnerId: selectedPartner.id,
+ projectId: id,
+ startedOn: startDate,
+ }),
+ token,
+ 'POST',
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
+ setDateRange({
+ startDate: new Date(),
+ endDate: null,
+ });
+ setSelectedPartner({});
+ toast.success( );
+ },
+ onError: () => {
+ toast.error( );
+ },
+ });
+
+ const partnerIdToDetailsMapping = useMemo(() => {
+ const mapping = {};
+ for (let i = 0; i < partners?.length; i++) {
+ mapping[partners[i].id] = partners[i];
+ }
+ return mapping;
+ }, [partners]);
+
+ const handleSave = () => {
+ if (!selectedPartner?.id) {
+ setErrorMessage(messages.partnerNotSelectedError);
+ return;
+ }
+
+ if (dateRange.endDate && dateRange.startDate > dateRange.endDate) {
+ setErrorMessage(messages.partnerEndDateError);
+ return;
+ }
+
+ savePartnerMutation.mutate();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
option.name}
+ getOptionValue={(option) => option.id}
+ options={partners}
+ value={selectedPartner.id ? selectedPartner : null}
+ placeholder={
+ isError ? (
+
+ ) : (
+
+ )
+ }
+ onChange={(value) => (value ? setSelectedPartner(value) : setSelectedPartner({}))}
+ />
+
+
+
+
+ setDateRange({
+ ...dateRange,
+ startDate: date,
+ })
+ }
+ dateFormat="dd/MM/yyyy"
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+
+ }
+ />
+
+
+
+
+
+
+
+ setDateRange({
+ ...dateRange,
+ endDate: date,
+ })
+ }
+ dateFormat="dd/MM/yyyy"
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+ {
+ setDateRange({
+ ...dateRange,
+ endDate: null,
+ });
+ }}
+ isStartDate={false}
+ inputStyles={{ maxWidth: '9rem' }}
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {errorMessage.id ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectEdit/partnersListing.js b/frontend/src/components/projectEdit/partnersListing.js
deleted file mode 100644
index bc58793656..0000000000
--- a/frontend/src/components/projectEdit/partnersListing.js
+++ /dev/null
@@ -1,439 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useParams } from 'react-router-dom';
-import { useSelector } from 'react-redux';
-import ReactDatePicker from 'react-datepicker';
-import { FormattedMessage } from 'react-intl';
-import Popup from 'reactjs-popup';
-import { Tooltip } from 'react-tooltip';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import ReactPlaceholder from 'react-placeholder';
-import { format } from 'date-fns';
-import toast from 'react-hot-toast';
-import PropTypes from 'prop-types';
-
-import messages from './messages';
-import { Alert } from '../alert';
-import { BanIcon, CircleMinusIcon, CircleExclamationIcon } from '../svgIcons';
-import { Button } from '../button';
-import { styleClasses } from '../../views/projectEdit';
-import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
-import { DateCustomInput } from './partnersForm';
-
-const TableContentPlaceholder = () => (
- <>
-
- {new Array(4).fill(
-
-
- ,
- )}
-
-
- {new Array(4).fill(
-
-
- ,
- )}
-
- >
-);
-
-export const Listing = ({ partnerIdToDetailsMapping }) => {
- const [selectedPartner, setSelectedPartner] = useState({});
- const [errorMessage, setErrorMessage] = useState({});
- const [actionType, setActionType] = useState(''); // "edit" or "remove"
-
- const token = useSelector((state) => state.auth.token);
- const { id } = useParams();
- const queryClient = useQueryClient();
-
- useEffect(() => {
- if (!actionType.length) {
- setSelectedPartner({});
- }
- }, [actionType]);
-
- // clear dateRange error messages when the right dates are picked
- useEffect(() => {
- const startDate = selectedPartner.startedOn && new Date(selectedPartner.startedOn);
- const endDate = selectedPartner.endedOn && new Date(selectedPartner.endedOn);
-
- if (!endDate && errorMessage.id === messages.partnerEndDateError.id) {
- setErrorMessage({});
- return;
- }
-
- // clear error message if present when the selected endDate is after startDate
- if (endDate && startDate < endDate && errorMessage.id === messages.partnerEndDateError.id) {
- setErrorMessage({});
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedPartner.startedOn, selectedPartner.endedOn]);
-
- const {
- isPending,
- isError,
- data: linkedPartners,
- isRefetching,
- } = useQuery({
- queryKey: ['linked-partners', id],
- queryFn: async () => {
- const response = await fetchLocalJSONAPI(`projects/${id}/partners/`);
- const sortedPartnershipsByStartDate = response.partnerships.sort((itemA, itemB) => {
- const dateA = new Date(itemA.startedOn);
- const dateB = new Date(itemB.startedOn);
- return dateB - dateA; // Descending order; Use dateA - dateB for ascending
- });
-
- return { partnerships: sortedPartnershipsByStartDate };
- },
- });
-
- const removePartnerMutation = useMutation({
- mutationFn: () => {
- return pushToLocalJSONAPI(
- `projects/partnerships/${selectedPartner.id}/`,
- null,
- token,
- 'DELETE',
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
- setActionType('');
- toast.success( );
- },
- onError: () => {
- toast.error( );
- },
- });
-
- const updatePartnerMutation = useMutation({
- mutationFn: () => {
- const startDate = `${format(selectedPartner.startedOn, 'yyyy-MM-dd')}T00:00:00.000Z`;
- const endDate = selectedPartner.endedOn
- ? `${format(selectedPartner.endedOn, 'yyyy-MM-dd')}T00:00:00.000Z`
- : null;
-
- return pushToLocalJSONAPI(
- `projects/partnerships/${selectedPartner.id}/`,
- JSON.stringify({
- endedOn: endDate,
- startedOn: startDate,
- }),
- token,
- 'PATCH',
- );
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
- setActionType('');
- toast.success( );
- },
- onError: () => {
- toast.error( );
- },
- });
-
- const handleUpdate = () => {
- const startDate = selectedPartner.startedOn && new Date(selectedPartner.startedOn);
- const endDate = selectedPartner.endedOn && new Date(selectedPartner.endedOn);
-
- if (endDate && startDate > endDate) {
- setErrorMessage(messages.partnerEndDateError);
- return;
- }
-
- updatePartnerMutation.mutate();
- };
-
- const getDateObjectAndDateString = (date) => {
- const [year, month, day] = date.split('T')[0].split('-');
- const dateString = `${day}/${month}/${year}`;
- const dateObject = new Date(year, month - 1, day);
- return [dateObject, dateString];
- };
-
- const isEmpty =
- !isPending && !isRefetching && !isError && linkedPartners?.partnerships?.length === 0;
-
- const tableContents = linkedPartners?.partnerships?.map((partner) => {
- const [startDate, startDateString] = getDateObjectAndDateString(partner.startedOn);
-
- let endDateString = 'N/A',
- endDate = null;
- if (partner.endedOn) {
- [endDate, endDateString] = getDateObjectAndDateString(partner.endedOn);
- }
-
- const isInactive = endDate && endDate < new Date();
-
- return (
- {
- setSelectedPartner({ ...partner, startedOn: startDate, endedOn: endDate });
- setActionType('edit');
- }}
- style={{ userSelect: 'none' }}
- >
-
- {partnerIdToDetailsMapping[partner.partnerId]?.name}
-
-
- {startDateString}
-
-
- {endDateString}
-
-
- {
- setSelectedPartner({ ...partner });
- setActionType('remove');
- }}
- data-tooltip-id="remove-partner-action"
- data-tooltip-content="Remove this partner"
- />
-
-
-
-
- );
- });
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- ready={!isPending && !isRefetching}
- showLoadingAnimation
- >
- {tableContents}
-
-
-
-
- {isError ? (
-
- ) : null}
-
- {isEmpty ? (
-
- ) : null}
-
-
-
setActionType('')}
- >
- {(close) => (
-
-
-
-
-
-
-
-
-
- {partnerIdToDetailsMapping[selectedPartner.partnerId]?.name}
-
- From
- {selectedPartner.startedOn
- ? format(new Date(selectedPartner.startedOn), 'dd/MM/yyyy')
- : 'N/A'}
- to
- {selectedPartner.endedOn
- ? format(new Date(selectedPartner.endedOn), 'dd/MM/yyyy')
- : 'N/A'}
-
-
-
-
- {
- setActionType('');
- close();
- }}
- >
-
-
-
- {
- removePartnerMutation.mutate();
- }}
- className={`${styleClasses.redButtonClass} br2`}
- loading={removePartnerMutation.isLoading}
- >
-
-
-
-
- )}
-
-
-
setActionType('')}
- contentStyle={{ overflow: 'visible' }}
- >
- {(close) => (
-
-
-
-
-
-
- {partnerIdToDetailsMapping[selectedPartner.partnerId]?.name}
-
-
-
-
-
-
-
- setSelectedPartner({
- ...selectedPartner,
- startedOn: date,
- })
- }
- dateFormat="dd/MM/yyyy"
- showYearDropdown
- scrollableYearDropdown
- customInput={
-
- }
- />
-
-
-
-
-
-
-
-
-
-
}
- className={styleClasses.inputClass}
- onChange={(date) =>
- setSelectedPartner({
- ...selectedPartner,
- endedOn: date,
- })
- }
- dateFormat="dd/MM/yyyy"
- showYearDropdown
- scrollableYearDropdown
- customInput={
-
- }
- />
-
-
-
-
-
- {errorMessage.id ? (
-
- ) : null}
-
-
-
- {
- setActionType('');
- close();
- }}
- >
-
-
-
-
-
-
-
-
- )}
-
-
- );
-};
-
-Listing.propTypes = {
- partnerIdToDetailsMapping: PropTypes.shape({
- id: PropTypes.shape({
- id: PropTypes.number.isRequired,
- name: PropTypes.string.isRequired,
- }),
- }).isRequired,
-};
diff --git a/frontend/src/components/projectEdit/partnersListing.jsx b/frontend/src/components/projectEdit/partnersListing.jsx
new file mode 100644
index 0000000000..77d8c4baa1
--- /dev/null
+++ b/frontend/src/components/projectEdit/partnersListing.jsx
@@ -0,0 +1,439 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { useTypedSelector } from '@Store/hooks';
+import ReactDatePicker from 'react-datepicker';
+import { FormattedMessage } from 'react-intl';
+import Popup from 'reactjs-popup';
+import { Tooltip } from 'react-tooltip';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import ReactPlaceholder from 'react-placeholder';
+import { format } from 'date-fns';
+import toast from 'react-hot-toast';
+import PropTypes from 'prop-types';
+
+import messages from './messages';
+import { Alert } from '../alert';
+import { BanIcon, CircleMinusIcon, CircleExclamationIcon } from '../svgIcons';
+import { Button } from '../button';
+import { styleClasses } from '../../views/projectEdit';
+import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest';
+import { DateCustomInput } from './partnersForm';
+
+const TableContentPlaceholder = () => (
+ <>
+
+ {new Array(4).fill(
+
+
+ ,
+ )}
+
+
+ {new Array(4).fill(
+
+
+ ,
+ )}
+
+ >
+);
+
+export const Listing = ({ partnerIdToDetailsMapping }) => {
+ const [selectedPartner, setSelectedPartner] = useState({});
+ const [errorMessage, setErrorMessage] = useState({});
+ const [actionType, setActionType] = useState(''); // "edit" or "remove"
+
+ const token = useTypedSelector((state) => state.auth.token);
+ const { id } = useParams();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!actionType.length) {
+ setSelectedPartner({});
+ }
+ }, [actionType]);
+
+ // clear dateRange error messages when the right dates are picked
+ useEffect(() => {
+ const startDate = selectedPartner.startedOn && new Date(selectedPartner.startedOn);
+ const endDate = selectedPartner.endedOn && new Date(selectedPartner.endedOn);
+
+ if (!endDate && errorMessage.id === messages.partnerEndDateError.id) {
+ setErrorMessage({});
+ return;
+ }
+
+ // clear error message if present when the selected endDate is after startDate
+ if (endDate && startDate < endDate && errorMessage.id === messages.partnerEndDateError.id) {
+ setErrorMessage({});
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedPartner.startedOn, selectedPartner.endedOn]);
+
+ const {
+ isPending,
+ isError,
+ data: linkedPartners,
+ isRefetching,
+ } = useQuery({
+ queryKey: ['linked-partners', id],
+ queryFn: async () => {
+ const response = await fetchLocalJSONAPI(`projects/${id}/partners/`);
+ const sortedPartnershipsByStartDate = response.partnerships.sort((itemA, itemB) => {
+ const dateA = new Date(itemA.startedOn);
+ const dateB = new Date(itemB.startedOn);
+ return dateB - dateA; // Descending order; Use dateA - dateB for ascending
+ });
+
+ return { partnerships: sortedPartnershipsByStartDate };
+ },
+ });
+
+ const removePartnerMutation = useMutation({
+ mutationFn: () => {
+ return pushToLocalJSONAPI(
+ `projects/partnerships/${selectedPartner.id}/`,
+ null,
+ token,
+ 'DELETE',
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
+ setActionType('');
+ toast.success( );
+ },
+ onError: () => {
+ toast.error( );
+ },
+ });
+
+ const updatePartnerMutation = useMutation({
+ mutationFn: () => {
+ const startDate = `${format(selectedPartner.startedOn, 'yyyy-MM-dd')}T00:00:00.000Z`;
+ const endDate = selectedPartner.endedOn
+ ? `${format(selectedPartner.endedOn, 'yyyy-MM-dd')}T00:00:00.000Z`
+ : null;
+
+ return pushToLocalJSONAPI(
+ `projects/partnerships/${selectedPartner.id}/`,
+ JSON.stringify({
+ endedOn: endDate,
+ startedOn: startDate,
+ }),
+ token,
+ 'PATCH',
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['linked-partners', id] });
+ setActionType('');
+ toast.success( );
+ },
+ onError: () => {
+ toast.error( );
+ },
+ });
+
+ const handleUpdate = () => {
+ const startDate = selectedPartner.startedOn && new Date(selectedPartner.startedOn);
+ const endDate = selectedPartner.endedOn && new Date(selectedPartner.endedOn);
+
+ if (endDate && startDate > endDate) {
+ setErrorMessage(messages.partnerEndDateError);
+ return;
+ }
+
+ updatePartnerMutation.mutate();
+ };
+
+ const getDateObjectAndDateString = (date) => {
+ const [year, month, day] = date.split('T')[0].split('-');
+ const dateString = `${day}/${month}/${year}`;
+ const dateObject = new Date(year, month - 1, day);
+ return [dateObject, dateString];
+ };
+
+ const isEmpty =
+ !isPending && !isRefetching && !isError && linkedPartners?.partnerships?.length === 0;
+
+ const tableContents = linkedPartners?.partnerships?.map((partner) => {
+ const [startDate, startDateString] = getDateObjectAndDateString(partner.startedOn);
+
+ let endDateString = 'N/A',
+ endDate = null;
+ if (partner.endedOn) {
+ [endDate, endDateString] = getDateObjectAndDateString(partner.endedOn);
+ }
+
+ const isInactive = endDate && endDate < new Date();
+
+ return (
+ {
+ setSelectedPartner({ ...partner, startedOn: startDate, endedOn: endDate });
+ setActionType('edit');
+ }}
+ style={{ userSelect: 'none' }}
+ >
+
+ {partnerIdToDetailsMapping[partner.partnerId]?.name}
+
+
+ {startDateString}
+
+
+ {endDateString}
+
+
+ {
+ setSelectedPartner({ ...partner });
+ setActionType('remove');
+ }}
+ data-tooltip-id="remove-partner-action"
+ data-tooltip-content="Remove this partner"
+ />
+
+
+
+
+ );
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ ready={!isPending && !isRefetching}
+ showLoadingAnimation
+ >
+ {tableContents}
+
+
+
+
+ {isError ? (
+
+ ) : null}
+
+ {isEmpty ? (
+
+ ) : null}
+
+
+
setActionType('')}
+ >
+ {(close) => (
+
+
+
+
+
+
+
+
+
+ {partnerIdToDetailsMapping[selectedPartner.partnerId]?.name}
+
+ From
+ {selectedPartner.startedOn
+ ? format(new Date(selectedPartner.startedOn), 'dd/MM/yyyy')
+ : 'N/A'}
+ to
+ {selectedPartner.endedOn
+ ? format(new Date(selectedPartner.endedOn), 'dd/MM/yyyy')
+ : 'N/A'}
+
+
+
+
+ {
+ setActionType('');
+ close();
+ }}
+ >
+
+
+
+ {
+ removePartnerMutation.mutate();
+ }}
+ className={`${styleClasses.redButtonClass} br2`}
+ loading={removePartnerMutation.isLoading}
+ >
+
+
+
+
+ )}
+
+
+
setActionType('')}
+ contentStyle={{ overflow: 'visible' }}
+ >
+ {(close) => (
+
+
+
+
+
+
+ {partnerIdToDetailsMapping[selectedPartner.partnerId]?.name}
+
+
+
+
+
+
+
+ setSelectedPartner({
+ ...selectedPartner,
+ startedOn: date,
+ })
+ }
+ dateFormat="dd/MM/yyyy"
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
}
+ className={styleClasses.inputClass}
+ onChange={(date) =>
+ setSelectedPartner({
+ ...selectedPartner,
+ endedOn: date,
+ })
+ }
+ dateFormat="dd/MM/yyyy"
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+
+ }
+ />
+
+
+
+
+
+ {errorMessage.id ? (
+
+ ) : null}
+
+
+
+ {
+ setActionType('');
+ close();
+ }}
+ >
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+Listing.propTypes = {
+ partnerIdToDetailsMapping: PropTypes.shape({
+ id: PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ }),
+ }).isRequired,
+};
diff --git a/frontend/src/components/projectEdit/permissionsBlock.js b/frontend/src/components/projectEdit/permissionsBlock.js
deleted file mode 100644
index c058cb8ab4..0000000000
--- a/frontend/src/components/projectEdit/permissionsBlock.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useContext, useEffect, useRef } from 'react';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages.js';
-import { StateContext, styleClasses } from '../../views/projectEdit';
-import { useTeamsQuery } from '../../api/teams';
-
-const globalValidatorPermissions = ['TEAMS', 'TEAMS_LEVEL'];
-const hotGlobalValidatorTeamName = 'HOT Global Validators';
-
-export const PermissionsBlock = ({ permissions, type }: Object) => {
- const { projectInfo, setProjectInfo } = useContext(StateContext);
- const { data: teamsData } = useTeamsQuery({ omitMemberList: true });
- const isGlobalValidatorAlreadyPresent = useRef(false);
-
- // check if global validator already present on teams
- useEffect(() => {
- isGlobalValidatorAlreadyPresent.current = projectInfo.teams.some(
- (team) => team.name === hotGlobalValidatorTeamName,
- );
- }, []); // eslint-disable-line -- run only on first render
-
- const handlePermissionChange = (value) => {
- let teams = projectInfo.teams;
- // validation permission case
- if (type === 'validationPermission') {
- const isGlobalValidatorCase = globalValidatorPermissions.includes(value);
- // add `HOT Global Validators` by default case
- if (isGlobalValidatorCase) {
- const globalValidatorTeam = teamsData?.teams?.find(
- (team) => team.name === hotGlobalValidatorTeamName,
- );
- if (
- globalValidatorTeam &&
- // check if hotGlobalValidator already present
- !projectInfo.teams.some((team) => team.name === hotGlobalValidatorTeamName)
- ) {
- const hotGlobalValidatorTeam = {
- teamId: globalValidatorTeam.teamId,
- name: globalValidatorTeam.name,
- role: 'VALIDATOR',
- };
- // add hotGlobalValidator to teams
- teams = [hotGlobalValidatorTeam, ...projectInfo.teams];
- }
- // remove hotGlobalValidator from team if not HOT Global Validator case
- } else if (!isGlobalValidatorAlreadyPresent.current) {
- teams = projectInfo.teams.filter((team) => team.name !== hotGlobalValidatorTeamName);
- }
- }
- // set project info
- setProjectInfo({
- ...projectInfo,
- [type]: value,
- teams,
- });
- };
-
- return (
-
-
- {type === 'mappingPermission' ? (
-
- ) : (
-
- )}
-
-
- {type === 'mappingPermission' ? (
-
- ) : (
-
- )}
-
- {permissions.map((permission) => (
-
- handlePermissionChange(permission.value)}
- type="radio"
- className={`radio-input input-reset pointer v-mid dib h2 w2 mr2 br-100 ba b--blue-light`}
- />
- {permission.label}
-
- ))}
-
- );
-};
diff --git a/frontend/src/components/projectEdit/permissionsBlock.jsx b/frontend/src/components/projectEdit/permissionsBlock.jsx
new file mode 100644
index 0000000000..df6e7c4200
--- /dev/null
+++ b/frontend/src/components/projectEdit/permissionsBlock.jsx
@@ -0,0 +1,88 @@
+import { useContext, useEffect, useRef } from 'react';
+import { FormattedMessage } from 'react-intl';
+import messages from './messages';
+import { StateContext, styleClasses } from '../../views/projectEdit';
+import { useTeamsQuery } from '../../api/teams';
+
+const globalValidatorPermissions = ['TEAMS', 'TEAMS_LEVEL'];
+const hotGlobalValidatorTeamName = 'HOT Global Validators';
+
+export const PermissionsBlock = ({ permissions, type }) => {
+ const { projectInfo, setProjectInfo } = useContext(StateContext);
+ const { data: teamsData } = useTeamsQuery({ omitMemberList: true });
+ const isGlobalValidatorAlreadyPresent = useRef(false);
+
+ // check if global validator already present on teams
+ useEffect(() => {
+ isGlobalValidatorAlreadyPresent.current = projectInfo.teams.some(
+ (team) => team.name === hotGlobalValidatorTeamName,
+ );
+ }, []); // eslint-disable-line -- run only on first render
+
+ const handlePermissionChange = (value) => {
+ let teams = projectInfo.teams;
+ // validation permission case
+ if (type === 'validationPermission') {
+ const isGlobalValidatorCase = globalValidatorPermissions.includes(value);
+ // add `HOT Global Validators` by default case
+ if (isGlobalValidatorCase) {
+ const globalValidatorTeam = teamsData?.teams?.find(
+ (team) => team.name === hotGlobalValidatorTeamName,
+ );
+ if (
+ globalValidatorTeam &&
+ // check if hotGlobalValidator already present
+ !projectInfo.teams.some((team) => team.name === hotGlobalValidatorTeamName)
+ ) {
+ const hotGlobalValidatorTeam = {
+ teamId: globalValidatorTeam.teamId,
+ name: globalValidatorTeam.name,
+ role: 'VALIDATOR',
+ };
+ // add hotGlobalValidator to teams
+ teams = [hotGlobalValidatorTeam, ...projectInfo.teams];
+ }
+ // remove hotGlobalValidator from team if not HOT Global Validator case
+ } else if (!isGlobalValidatorAlreadyPresent.current) {
+ teams = projectInfo.teams.filter((team) => team.name !== hotGlobalValidatorTeamName);
+ }
+ }
+ // set project info
+ setProjectInfo({
+ ...projectInfo,
+ [type]: value,
+ teams,
+ });
+ };
+
+ return (
+
+
+ {type === 'mappingPermission' ? (
+
+ ) : (
+
+ )}
+
+
+ {type === 'mappingPermission' ? (
+
+ ) : (
+
+ )}
+
+ {permissions.map((permission) => (
+
+ handlePermissionChange(permission.value)}
+ type="radio"
+ className={`radio-input input-reset pointer v-mid dib h2 w2 mr2 br-100 ba b--blue-light`}
+ />
+ {permission.label}
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/components/projectEdit/permissionsForm.js b/frontend/src/components/projectEdit/permissionsForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/permissionsForm.js
rename to frontend/src/components/projectEdit/permissionsForm.jsx
diff --git a/frontend/src/components/projectEdit/priorityAreasForm.js b/frontend/src/components/projectEdit/priorityAreasForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/priorityAreasForm.js
rename to frontend/src/components/projectEdit/priorityAreasForm.jsx
diff --git a/frontend/src/components/projectEdit/projectInterests.js b/frontend/src/components/projectEdit/projectInterests.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/projectInterests.js
rename to frontend/src/components/projectEdit/projectInterests.jsx
diff --git a/frontend/src/components/projectEdit/settingsForm.js b/frontend/src/components/projectEdit/settingsForm.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/settingsForm.js
rename to frontend/src/components/projectEdit/settingsForm.jsx
diff --git a/frontend/src/components/projectEdit/teamSelect.js b/frontend/src/components/projectEdit/teamSelect.jsx
similarity index 100%
rename from frontend/src/components/projectEdit/teamSelect.js
rename to frontend/src/components/projectEdit/teamSelect.jsx
diff --git a/frontend/src/components/projectEdit/tests/localeOption.test.js b/frontend/src/components/projectEdit/tests/localeOption.test.js
deleted file mode 100644
index 7a081d3683..0000000000
--- a/frontend/src/components/projectEdit/tests/localeOption.test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { LocaleOption } from '../localeOption';
-import userEvent from '@testing-library/user-event';
-
-describe('LocaleOption', () => {
- const mockFn = jest.fn();
- it('with isActive = true', async () => {
- const user = userEvent.setup();
- render(
- ,
- );
- expect(screen.getByText('es').className).toContain(
- 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
- );
- expect(screen.getByText('es').className).toContain('bg-blue-grey fw6 white');
- expect(screen.getByText('es').title).toBe('Español');
- await user.click(screen.getByText('es'));
- expect(mockFn).toHaveBeenCalledWith('es');
- });
- it('with isActive = false and hasValue = true', () => {
- render(
- ,
- );
- expect(screen.getByText('pt').className).toContain(
- 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
- );
- expect(screen.getByText('pt').className).toContain('bg-white fw6 blue-dark');
- expect(screen.getByText('pt').title).toBe('Português');
- });
- it('with isActive = false and hasValue = false', () => {
- render(
- ,
- );
- expect(screen.getByText('it').className).toContain(
- 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
- );
- expect(screen.getByText('it').className).toContain('bg-white blue-grey');
- expect(screen.getByText('it').title).toBe('Italiano');
- });
-});
diff --git a/frontend/src/components/projectEdit/tests/localeOption.test.jsx b/frontend/src/components/projectEdit/tests/localeOption.test.jsx
new file mode 100644
index 0000000000..7f4f2eafae
--- /dev/null
+++ b/frontend/src/components/projectEdit/tests/localeOption.test.jsx
@@ -0,0 +1,60 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { LocaleOption } from '../localeOption';
+import userEvent from '@testing-library/user-event';
+
+describe('LocaleOption', () => {
+ const mockFn = vi.fn();
+ it('with isActive = true', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ expect(screen.getByText('es').className).toContain(
+ 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
+ );
+ expect(screen.getByText('es').className).toContain('bg-blue-grey fw6 white');
+ expect(screen.getByText('es').title).toBe('Español');
+ await user.click(screen.getByText('es'));
+ expect(mockFn).toHaveBeenCalledWith('es');
+ });
+ it('with isActive = false and hasValue = true', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('pt').className).toContain(
+ 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
+ );
+ expect(screen.getByText('pt').className).toContain('bg-white fw6 blue-dark');
+ expect(screen.getByText('pt').title).toBe('Português');
+ });
+ it('with isActive = false and hasValue = false', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('it').className).toContain(
+ 'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
+ );
+ expect(screen.getByText('it').className).toContain('bg-white blue-grey');
+ expect(screen.getByText('it').title).toBe('Italiano');
+ });
+});
diff --git a/frontend/src/components/projectStats/completion.js b/frontend/src/components/projectStats/completion.js
deleted file mode 100644
index ab96b71a77..0000000000
--- a/frontend/src/components/projectStats/completion.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-
-import messages from './messages';
-
-export const CompletionStats = ({ tasksByStatus }: Object) => {
- const tasksToMap = tasksByStatus.invalidated + tasksByStatus.ready;
- const tasksToValidate =
- tasksByStatus.totalTasks -
- tasksByStatus.validated -
- tasksByStatus.lockedForValidation -
- tasksByStatus.badImagery;
- return (
-
-
-
-
- / {tasksByStatus.totalTasks}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/projectStats/completion.jsx b/frontend/src/components/projectStats/completion.jsx
new file mode 100644
index 0000000000..61c9c1edb2
--- /dev/null
+++ b/frontend/src/components/projectStats/completion.jsx
@@ -0,0 +1,33 @@
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+
+import messages from './messages';
+
+export const CompletionStats = ({ tasksByStatus }) => {
+ const tasksToMap = tasksByStatus.invalidated + tasksByStatus.ready;
+ const tasksToValidate =
+ tasksByStatus.totalTasks -
+ tasksByStatus.validated -
+ tasksByStatus.lockedForValidation -
+ tasksByStatus.badImagery;
+ return (
+
+
+
+
+ / {tasksByStatus.totalTasks}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/projectStats/contributorsStats.js b/frontend/src/components/projectStats/contributorsStats.jsx
similarity index 100%
rename from frontend/src/components/projectStats/contributorsStats.js
rename to frontend/src/components/projectStats/contributorsStats.jsx
diff --git a/frontend/src/components/projectStats/edits.js b/frontend/src/components/projectStats/edits.jsx
similarity index 100%
rename from frontend/src/components/projectStats/edits.js
rename to frontend/src/components/projectStats/edits.jsx
diff --git a/frontend/src/components/projectStats/messages.js b/frontend/src/components/projectStats/messages.ts
similarity index 100%
rename from frontend/src/components/projectStats/messages.js
rename to frontend/src/components/projectStats/messages.ts
diff --git a/frontend/src/components/projectStats/taskStatus.js b/frontend/src/components/projectStats/taskStatus.jsx
similarity index 100%
rename from frontend/src/components/projectStats/taskStatus.js
rename to frontend/src/components/projectStats/taskStatus.jsx
diff --git a/frontend/src/components/projectStats/tests/completion.test.js b/frontend/src/components/projectStats/tests/completion.test.js
deleted file mode 100644
index f384aa7f93..0000000000
--- a/frontend/src/components/projectStats/tests/completion.test.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import { CompletionStats } from '../completion';
-
-describe('', () => {
- it('', () => {
- const stats = {
- ready: 168,
- badImagery: 3,
- lockedForMapping: 4,
- mapped: 21,
- lockedForValidation: 6,
- validated: 2,
- invalidated: 9,
- totalTasks: 213,
- };
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('Tasks to map').className).toBe('ma0 h2 f4 fw6 blue-grey ttl');
- expect(screen.getByText('177').className).toBe('ma0 mb2 barlow-condensed f1 b red');
- expect(screen.getByText('/ 213').className).toBe('dib f3 pl2 blue-grey');
- expect(screen.getByText('Tasks to validate').className).toBe('ma0 h2 f4 fw6 blue-grey ttl');
- expect(screen.getByText('202').className).toBe('ma0 mb2 barlow-condensed f1 b red');
- expect(container.querySelectorAll('h3').length).toBe(2);
- });
-});
diff --git a/frontend/src/components/projectStats/tests/completion.test.jsx b/frontend/src/components/projectStats/tests/completion.test.jsx
new file mode 100644
index 0000000000..6e13060b90
--- /dev/null
+++ b/frontend/src/components/projectStats/tests/completion.test.jsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import { CompletionStats } from '../completion';
+
+describe('', () => {
+ it('', () => {
+ const stats = {
+ ready: 168,
+ badImagery: 3,
+ lockedForMapping: 4,
+ mapped: 21,
+ lockedForValidation: 6,
+ validated: 2,
+ invalidated: 9,
+ totalTasks: 213,
+ };
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('Tasks to map').className).toBe('ma0 h2 f4 fw6 blue-grey ttl');
+ expect(screen.getByText('177').className).toBe('ma0 mb2 barlow-condensed f1 b red');
+ expect(screen.getByText('/ 213').className).toBe('dib f3 pl2 blue-grey');
+ expect(screen.getByText('Tasks to validate').className).toBe('ma0 h2 f4 fw6 blue-grey ttl');
+ expect(screen.getByText('202').className).toBe('ma0 mb2 barlow-condensed f1 b red');
+ expect(container.querySelectorAll('h3').length).toBe(2);
+ });
+});
diff --git a/frontend/src/components/projectStats/tests/contributorsStats.test.js b/frontend/src/components/projectStats/tests/contributorsStats.test.js
deleted file mode 100644
index 5235b430b3..0000000000
--- a/frontend/src/components/projectStats/tests/contributorsStats.test.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Provider } from 'react-redux';
-import { render, waitFor } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { store } from '../../../store';
-import { ConnectedIntl } from '../../../utils/internationalization';
-import { projectContributions } from '../../../network/tests/mockData/contributions';
-import ContributorsStats from '../contributorsStats';
-
-jest.mock('react-chartjs-2', () => ({
- Doughnut: () => null,
- Bar: () => null,
-}));
-
-test('ContributorsStats renders the correct labels and numbers', async () => {
- const { getByText } = render(
-
-
-
-
- ,
- );
- await waitFor(() => expect(getByText('4')).toBeInTheDocument());
- expect(getByText('3')).toBeInTheDocument();
- expect(getByText('5')).toBeInTheDocument();
- expect(getByText('Mappers')).toBeInTheDocument();
- expect(getByText('Validators')).toBeInTheDocument();
- expect(getByText('Total contributors')).toBeInTheDocument();
- expect(getByText('Users by experience on Tasking Manager')).toBeInTheDocument();
- expect(getByText('Users by level')).toBeInTheDocument();
-});
-
-test('ContributorsStats renders values as 0 if the project did not received contributions', async () => {
- const { getAllByText } = render(
-
-
-
-
- ,
- );
- await waitFor(() => expect(getAllByText('0').length).toBe(3));
- expect(getAllByText('0').length).toBe(3);
-});
diff --git a/frontend/src/components/projectStats/tests/contributorsStats.test.jsx b/frontend/src/components/projectStats/tests/contributorsStats.test.jsx
new file mode 100644
index 0000000000..e23b5ac2d7
--- /dev/null
+++ b/frontend/src/components/projectStats/tests/contributorsStats.test.jsx
@@ -0,0 +1,43 @@
+import { Provider } from 'react-redux';
+import { render, waitFor } from '@testing-library/react';
+
+
+import { store } from '../../../store';
+import { ConnectedIntl } from '../../../utils/internationalization';
+import { projectContributions } from '../../../network/tests/mockData/contributions';
+import ContributorsStats from '../contributorsStats';
+
+vi.mock('react-chartjs-2', () => ({
+ Doughnut: () => null,
+ Bar: () => null,
+}));
+
+test('ContributorsStats renders the correct labels and numbers', async () => {
+ const { getByText } = render(
+
+
+
+
+ ,
+ );
+ await waitFor(() => expect(getByText('4')).toBeInTheDocument());
+ expect(getByText('3')).toBeInTheDocument();
+ expect(getByText('5')).toBeInTheDocument();
+ expect(getByText('Mappers')).toBeInTheDocument();
+ expect(getByText('Validators')).toBeInTheDocument();
+ expect(getByText('Total contributors')).toBeInTheDocument();
+ expect(getByText('Users by experience on Tasking Manager')).toBeInTheDocument();
+ expect(getByText('Users by level')).toBeInTheDocument();
+});
+
+test('ContributorsStats renders values as 0 if the project did not received contributions', async () => {
+ const { getAllByText } = render(
+
+
+
+
+ ,
+ );
+ await waitFor(() => expect(getAllByText('0').length).toBe(3));
+ expect(getAllByText('0').length).toBe(3);
+});
diff --git a/frontend/src/components/projectStats/tests/edits.test.js b/frontend/src/components/projectStats/tests/edits.test.js
deleted file mode 100644
index 547a978589..0000000000
--- a/frontend/src/components/projectStats/tests/edits.test.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Provider } from 'react-redux';
-import { render, waitFor } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { EditsStats } from '../edits';
-import { ConnectedIntl } from '../../../utils/internationalization';
-import { QueryClientProviders } from '../../../utils/testWithIntl';
-import { store } from '../../../store';
-
-describe('EditsStats component', () => {
- const data = {
- changesets: 22153,
- roads: 2739.51998662114,
- buildings: 269809,
- edits: 310483,
- };
-
- it('render contents', async () => {
- const { getByText } = render(
-
-
-
-
-
-
- ,
- );
-
- await waitFor(() => expect(getByText('Changesets')).toBeInTheDocument());
- expect(getByText('Changesets')).toBeInTheDocument();
- expect(getByText('Buildings mapped')).toBeInTheDocument();
- expect(getByText('Km road mapped')).toBeInTheDocument();
- expect(getByText('Total map edits')).toBeInTheDocument();
- expect(getByText('310,483')).toBeInTheDocument();
- expect(getByText('22,153')).toBeInTheDocument();
- expect(getByText('2,739')).toBeInTheDocument();
- expect(getByText('269,809')).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectStats/tests/edits.test.jsx b/frontend/src/components/projectStats/tests/edits.test.jsx
new file mode 100644
index 0000000000..1b6e4756d7
--- /dev/null
+++ b/frontend/src/components/projectStats/tests/edits.test.jsx
@@ -0,0 +1,39 @@
+import { Provider } from 'react-redux';
+import { render, waitFor } from '@testing-library/react';
+
+
+import { EditsStats } from '../edits';
+import { ConnectedIntl } from '../../../utils/internationalization';
+import { QueryClientProviders } from '../../../utils/testWithIntl';
+import { store } from '../../../store';
+
+describe('EditsStats component', () => {
+ const data = {
+ changesets: 22153,
+ roads: 2739.51998662114,
+ buildings: 269809,
+ edits: 310483,
+ };
+
+ it('render contents', async () => {
+ const { getByText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(getByText('Changesets')).toBeInTheDocument());
+ expect(getByText('Changesets')).toBeInTheDocument();
+ expect(getByText('Buildings mapped')).toBeInTheDocument();
+ expect(getByText('Km road mapped')).toBeInTheDocument();
+ expect(getByText('Total map edits')).toBeInTheDocument();
+ expect(getByText('310,483')).toBeInTheDocument();
+ expect(getByText('22,153')).toBeInTheDocument();
+ expect(getByText('2,739')).toBeInTheDocument();
+ expect(getByText('269,809')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectStats/tests/taskStats.test.js b/frontend/src/components/projectStats/tests/taskStats.test.js
deleted file mode 100644
index 854d52ddf8..0000000000
--- a/frontend/src/components/projectStats/tests/taskStats.test.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import TasksByStatus from '../taskStatus';
-
-jest.mock('react-chartjs-2', () => ({
- Doughnut: () => null,
-}));
-
-describe('', () => {
- const stats = {
- ready: 168,
- badImagery: 3,
- lockedForMapping: 4,
- mapped: 21,
- lockedForValidation: 6,
- validated: 2,
- invalidated: 9,
- totalTasks: 213,
- };
- render(
-
-
- ,
- );
- it('', () => {
- expect(screen.getByText('Tasks by status').className).toBe('barlow-condensed ttu f3');
- expect(screen.getByText('168')).toBeInTheDocument();
- expect(screen.getByText('3')).toBeInTheDocument();
- expect(screen.getByText('4')).toBeInTheDocument();
- expect(screen.getByText('21')).toBeInTheDocument();
- expect(screen.getByText('6')).toBeInTheDocument();
- expect(screen.getByText('2')).toBeInTheDocument();
- expect(screen.getByText('9')).toBeInTheDocument();
- expect(screen.getByText('More mapping needed')).toBeInTheDocument();
- expect(screen.getByText('Available for mapping')).toBeInTheDocument();
- expect(screen.getByText('Locked for mapping')).toBeInTheDocument();
- expect(screen.getByText('Ready for validation')).toBeInTheDocument();
- expect(screen.getByText('Locked for validation')).toBeInTheDocument();
- expect(screen.getByText('Finished')).toBeInTheDocument();
- expect(screen.getByText('Unavailable')).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/projectStats/tests/taskStats.test.jsx b/frontend/src/components/projectStats/tests/taskStats.test.jsx
new file mode 100644
index 0000000000..7eab76f766
--- /dev/null
+++ b/frontend/src/components/projectStats/tests/taskStats.test.jsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import TasksByStatus from '../taskStatus';
+
+vi.mock('react-chartjs-2', () => ({
+ Doughnut: () => null,
+}));
+
+describe('', () => {
+ const stats = {
+ ready: 168,
+ badImagery: 3,
+ lockedForMapping: 4,
+ mapped: 21,
+ lockedForValidation: 6,
+ validated: 2,
+ invalidated: 9,
+ totalTasks: 213,
+ };
+ render(
+
+
+ ,
+ );
+ it('', () => {
+ expect(screen.getByText('Tasks by status').className).toBe('barlow-condensed ttu f3');
+ expect(screen.getByText('168')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText('4')).toBeInTheDocument();
+ expect(screen.getByText('21')).toBeInTheDocument();
+ expect(screen.getByText('6')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ expect(screen.getByText('9')).toBeInTheDocument();
+ expect(screen.getByText('More mapping needed')).toBeInTheDocument();
+ expect(screen.getByText('Available for mapping')).toBeInTheDocument();
+ expect(screen.getByText('Locked for mapping')).toBeInTheDocument();
+ expect(screen.getByText('Ready for validation')).toBeInTheDocument();
+ expect(screen.getByText('Locked for validation')).toBeInTheDocument();
+ expect(screen.getByText('Finished')).toBeInTheDocument();
+ expect(screen.getByText('Unavailable')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/projectStats/timeStats.js b/frontend/src/components/projectStats/timeStats.js
deleted file mode 100644
index 4c37aefb51..0000000000
--- a/frontend/src/components/projectStats/timeStats.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import ReactPlaceholder from 'react-placeholder';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { shortEnglishHumanizer } from '../userDetail/elementsMapped';
-import { StatsCardContent } from '../statsCard';
-import { MappedIcon, ValidatedIcon } from '../svgIcons';
-import { useProjectStatisticsQuery } from '../../api/stats';
-import { Alert } from '../alert';
-
-const StatsRow = ({ stats }) => {
- const fields = [
- 'averageMappingTime',
- 'averageValidationTime',
- 'timeToFinishMapping',
- 'timeToFinishValidating',
- ];
-
- const options = {
- units: ['h', 'm', 's'],
- round: true,
- spacer: '',
- };
-
- return (
-
- {fields.map((t, n) => (
-
-
-
- {t.indexOf('Mapping') !== -1 ? (
-
- ) : (
-
- )}
-
-
- }
- />
-
-
-
- ))}
-
- );
-};
-
-const StatsCards = ({ stats }) => {
- return (
-
-
-
-
-
-
- );
-};
-
-export const TimeStats = ({ id }) => {
- const { data: stats, status } = useProjectStatisticsQuery(id);
-
- if (status === 'loading') {
- return ;
- }
- if (status === 'error') {
- return (
-
-
-
- );
- }
-
- return ;
-};
diff --git a/frontend/src/components/projectStats/timeStats.jsx b/frontend/src/components/projectStats/timeStats.jsx
new file mode 100644
index 0000000000..1270136897
--- /dev/null
+++ b/frontend/src/components/projectStats/timeStats.jsx
@@ -0,0 +1,77 @@
+import ReactPlaceholder from 'react-placeholder';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { shortEnglishHumanizer } from '../userDetail/elementsMapped';
+import { StatsCardContent } from '../statsCard';
+import { MappedIcon, ValidatedIcon } from '../svgIcons';
+import { useProjectStatisticsQuery } from '../../api/stats';
+import { Alert } from '../alert';
+
+const StatsRow = ({ stats }) => {
+ const fields = [
+ 'averageMappingTime',
+ 'averageValidationTime',
+ 'timeToFinishMapping',
+ 'timeToFinishValidating',
+ ];
+
+ const options = {
+ units: ['h', 'm', 's'],
+ round: true,
+ spacer: '',
+ };
+
+ return (
+
+ {fields.map((t, n) => (
+
+
+
+ {t.indexOf('Mapping') !== -1 ? (
+
+ ) : (
+
+ )}
+
+
+ }
+ />
+
+
+
+ ))}
+
+ );
+};
+
+const StatsCards = ({ stats }) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const TimeStats = ({ id }) => {
+ const { data: stats, status } = useProjectStatisticsQuery(id);
+
+ if (status === 'pending') {
+ return ;
+ }
+ if (status === 'error') {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+};
diff --git a/frontend/src/components/projects/clearFilters.js b/frontend/src/components/projects/clearFilters.js
deleted file mode 100644
index 2104594b24..0000000000
--- a/frontend/src/components/projects/clearFilters.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Link } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-
-export default function ClearFilters({ url, className = '' }: Object) {
- return (
-
-
-
- );
-}
diff --git a/frontend/src/components/projects/clearFilters.tsx b/frontend/src/components/projects/clearFilters.tsx
new file mode 100644
index 0000000000..85af8d55d7
--- /dev/null
+++ b/frontend/src/components/projects/clearFilters.tsx
@@ -0,0 +1,15 @@
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+export default function ClearFilters({ url, className = '' }: {
+ url: string;
+ className?: string;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/projects/downloadAsCSV.js b/frontend/src/components/projects/downloadAsCSV.js
deleted file mode 100644
index 66bed73516..0000000000
--- a/frontend/src/components/projects/downloadAsCSV.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useState } from 'react';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-import { useSelector } from 'react-redux';
-import toast from 'react-hot-toast';
-
-import { downloadAsCSV } from '../../api/projects';
-import { DownloadIcon, LoadingIcon } from '../svgIcons';
-import messages from './messages';
-
-export default function DownloadAsCSV({ allQueryParams }) {
- const [isLoading, setIsLoading] = useState(false);
- const token = useSelector((state) => state.auth.token);
- const action = useSelector((state) => state.preferences['action']);
-
- const allQueryParamsCopy = { ...allQueryParams };
- allQueryParamsCopy.downloadAsCSV = true;
- allQueryParamsCopy.omitMapResults = undefined;
-
- const handleDownload = async () => {
- setIsLoading(true);
-
- try {
- const response = await downloadAsCSV(allQueryParamsCopy, action, token);
-
- // Get the filename from the Content-Disposition header, if available
- const contentDisposition = response.headers.get('Content-Disposition');
- let filename = 'projects_result.csv';
- if (contentDisposition) {
- const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
- if (filenameMatch) {
- filename = filenameMatch[1];
- }
- }
-
- // Create a Blob with the CSV content
- const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
-
- // Create and click a temporary download link
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.setAttribute('download', filename);
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- // Clean up the URL object
- window.URL.revokeObjectURL(url);
- } catch (error) {
- toast.error( );
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
-
- {isLoading ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
-DownloadAsCSV.propTypes = {
- allQueryParams: PropTypes.string.isRequired,
-};
diff --git a/frontend/src/components/projects/downloadAsCSV.jsx b/frontend/src/components/projects/downloadAsCSV.jsx
new file mode 100644
index 0000000000..a735327419
--- /dev/null
+++ b/frontend/src/components/projects/downloadAsCSV.jsx
@@ -0,0 +1,80 @@
+import { useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import { useTypedSelector } from '@Store/hooks';
+import toast from 'react-hot-toast';
+
+import { downloadAsCSV } from '../../api/projects';
+import { DownloadIcon, LoadingIcon } from '../svgIcons';
+import messages from './messages';
+
+export default function DownloadAsCSV({ allQueryParams }) {
+ const [isLoading, setIsLoading] = useState(false);
+ const token = useTypedSelector((state) => state.auth.token);
+ const action = useTypedSelector((state) => state.preferences['action']);
+
+ const allQueryParamsCopy = { ...allQueryParams };
+ allQueryParamsCopy.downloadAsCSV = true;
+ allQueryParamsCopy.omitMapResults = undefined;
+
+ const handleDownload = async () => {
+ setIsLoading(true);
+
+ try {
+ const response = await downloadAsCSV(allQueryParamsCopy, action, token);
+
+ // Get the filename from the Content-Disposition header, if available
+ const contentDisposition = response.headers.get('Content-Disposition');
+ let filename = 'projects_result.csv';
+ if (contentDisposition) {
+ const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
+ if (filenameMatch) {
+ filename = filenameMatch[1];
+ }
+ }
+
+ // Create a Blob with the CSV content
+ const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
+
+ // Create and click a temporary download link
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', filename);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up the URL object
+ window.URL.revokeObjectURL(url);
+ } catch {
+ toast.error( );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+DownloadAsCSV.propTypes = {
+ allQueryParams: PropTypes.object.isRequired,
+};
diff --git a/frontend/src/components/projects/exploreProjectsTable.js b/frontend/src/components/projects/exploreProjectsTable.jsx
similarity index 100%
rename from frontend/src/components/projects/exploreProjectsTable.js
rename to frontend/src/components/projects/exploreProjectsTable.jsx
diff --git a/frontend/src/components/projects/filterSelectFields.js b/frontend/src/components/projects/filterSelectFields.jsx
similarity index 100%
rename from frontend/src/components/projects/filterSelectFields.js
rename to frontend/src/components/projects/filterSelectFields.jsx
diff --git a/frontend/src/components/projects/list.js b/frontend/src/components/projects/list.js
deleted file mode 100644
index b9afb86a5a..0000000000
--- a/frontend/src/components/projects/list.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { Link } from 'react-router-dom';
-import { Tooltip } from 'react-tooltip';
-import { FormattedMessage } from 'react-intl';
-
-import messages from '../projectCard/messages';
-import { MapIcon, MappedIcon, ValidatedIcon, GearIcon, UserIcon } from '../svgIcons';
-import { ProjectStatusBox } from '../projectDetail/statusBox';
-import { PriorityBox } from '../projectCard/priorityBox';
-
-export function ProjectListItem({ project }: Object) {
- return (
-
-
-
-
-
-
-
- #{project.projectId} {' '}
- {project.name}
-
-
-
-
- {(msg) => (
- <>
-
-
- {project.percentMapped}%
-
-
- >
- )}
-
-
- {(msg) => (
- <>
-
-
- {project.percentValidated}%
-
-
- >
- )}
-
-
- {(msg) => (
- <>
-
-
- {project.totalContributors}
-
-
- >
- )}
-
-
- {['DRAFT', 'ARCHIVED'].includes(project.status) ? (
-
- ) : (
-
- )}
-
-
-
-
- {(msg) => (
-
-
-
- )}
-
-
- {(msg) => (
-
-
-
- )}
-
-
-
-
- );
-}
diff --git a/frontend/src/components/projects/list.jsx b/frontend/src/components/projects/list.jsx
new file mode 100644
index 0000000000..510a0bdc95
--- /dev/null
+++ b/frontend/src/components/projects/list.jsx
@@ -0,0 +1,115 @@
+import { Link } from 'react-router-dom';
+import { Tooltip } from 'react-tooltip';
+import { FormattedMessage } from 'react-intl';
+
+import messages from '../projectCard/messages';
+import { MapIcon, MappedIcon, ValidatedIcon, GearIcon, UserIcon } from '../svgIcons';
+import { ProjectStatusBox } from '../projectDetail/statusBox';
+import { PriorityBox } from '../projectCard/priorityBox';
+
+export function ProjectListItem({ project }) {
+ return (
+
+
+
+
+
+
+
+ #{project.projectId} {' '}
+ {project.name}
+
+
+
+
+ {(msg) => (
+ <>
+
+
+ {project.percentMapped}%
+
+
+ >
+ )}
+
+
+ {(msg) => (
+ <>
+
+
+ {project.percentValidated}%
+
+
+ >
+ )}
+
+
+ {(msg) => (
+ <>
+
+
+ {project.totalContributors}
+
+
+ >
+ )}
+
+
+ {['DRAFT', 'ARCHIVED'].includes(project.status) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {(msg) => (
+
+
+
+ )}
+
+
+ {(msg) => (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/projects/mappingTypeFilterPicker.js b/frontend/src/components/projects/mappingTypeFilterPicker.jsx
similarity index 100%
rename from frontend/src/components/projects/mappingTypeFilterPicker.js
rename to frontend/src/components/projects/mappingTypeFilterPicker.jsx
diff --git a/frontend/src/components/projects/messages.js b/frontend/src/components/projects/messages.ts
similarity index 100%
rename from frontend/src/components/projects/messages.js
rename to frontend/src/components/projects/messages.ts
diff --git a/frontend/src/components/projects/moreFiltersForm.js b/frontend/src/components/projects/moreFiltersForm.js
deleted file mode 100644
index 90744613d8..0000000000
--- a/frontend/src/components/projects/moreFiltersForm.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useSelector } from 'react-redux';
-import { Link } from 'react-router-dom';
-import { useQueryParam, BooleanParam } from 'use-query-params';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { Button } from '../button';
-import { SwitchToggle } from '../formInputs';
-import { useTagAPI } from '../../hooks/UseTagAPI';
-import { useExploreProjectsQueryParams } from '../../hooks/UseProjectsQueryAPI';
-import { MappingTypeFilterPicker } from './mappingTypeFilterPicker';
-import { ProjectFilterSelect } from './filterSelectFields';
-import { PartnersFilterSelect } from './partnersFilterSelect';
-import { CommaArrayParam } from '../../utils/CommaArrayParam';
-import { formatFilterCountriesData } from '../../utils/countries';
-
-export const MoreFiltersForm = (props) => {
- /* one useQueryParams for the main form */
- const isLoggedIn = useSelector((state) => state.auth.token);
- const userDetails = useSelector((state) => state.auth.userDetails);
- const [formQuery, setFormQuery] = useExploreProjectsQueryParams();
- const isAdmin = userDetails && userDetails.role === 'ADMIN';
-
- /* dereference the formQuery */
- const {
- campaign: campaignInQuery,
- organisation: orgInQuery,
- location: countryInQuery,
- interests: interestInQuery,
- } = formQuery;
- const [campaignAPIState] = useTagAPI([], 'campaigns');
- const [orgAPIState] = useTagAPI([], 'organisations');
- const [countriesAPIState] = useTagAPI([], 'countries', formatFilterCountriesData);
- const [interestAPIState] = useTagAPI([], 'interests');
-
- const [mappingTypesInQuery, setMappingTypes] = useQueryParam('types', CommaArrayParam);
- const [exactTypes, setExactTypes] = useQueryParam('exactTypes', BooleanParam);
-
- const fieldsetStyle = 'w-100 bn';
- const titleStyle = 'w-100 db ttu fw5 blue-grey';
-
- const extraFilters = [
- {
- fieldsetName: 'campaign',
- selectedTag: campaignInQuery,
- options: campaignAPIState,
- },
- {
- fieldsetName: 'organisation',
- selectedTag: orgInQuery,
- options: orgAPIState,
- },
- {
- fieldsetName: 'location',
- selectedTag: countryInQuery,
- options: countriesAPIState,
- payloadKey: 'value',
- },
- {
- fieldsetName: 'interests',
- selectedTag: interestInQuery,
- options: interestAPIState,
- payloadKey: 'id',
- },
- ];
-
- return (
-
- );
-};
diff --git a/frontend/src/components/projects/moreFiltersForm.jsx b/frontend/src/components/projects/moreFiltersForm.jsx
new file mode 100644
index 0000000000..34dfb38137
--- /dev/null
+++ b/frontend/src/components/projects/moreFiltersForm.jsx
@@ -0,0 +1,148 @@
+import { useTypedSelector } from '@Store/hooks';
+import { Link } from 'react-router-dom';
+import { useQueryParam, BooleanParam } from 'use-query-params';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { Button } from '../button';
+import { SwitchToggle } from '../formInputs';
+import { useTagAPI } from '../../hooks/UseTagAPI';
+import { useExploreProjectsQueryParams } from '../../hooks/UseProjectsQueryAPI';
+import { MappingTypeFilterPicker } from './mappingTypeFilterPicker';
+import { ProjectFilterSelect } from './filterSelectFields';
+import { PartnersFilterSelect } from './partnersFilterSelect';
+import { CommaArrayParam } from '../../utils/CommaArrayParam';
+import { formatFilterCountriesData } from '../../utils/countries';
+
+export const MoreFiltersForm = (props) => {
+ /* one useQueryParams for the main form */
+ const isLoggedIn = useTypedSelector((state) => state.auth.token);
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const [formQuery, setFormQuery] = useExploreProjectsQueryParams();
+ const isAdmin = userDetails && userDetails.role === 'ADMIN';
+
+ /* dereference the formQuery */
+ const {
+ campaign: campaignInQuery,
+ organisation: orgInQuery,
+ location: countryInQuery,
+ interests: interestInQuery,
+ } = formQuery;
+ const [campaignAPIState] = useTagAPI([], 'campaigns');
+ const [orgAPIState] = useTagAPI([], 'organisations');
+ const [countriesAPIState] = useTagAPI([], 'countries', formatFilterCountriesData);
+ const [interestAPIState] = useTagAPI([], 'interests');
+
+ const [mappingTypesInQuery, setMappingTypes] = useQueryParam('types', CommaArrayParam);
+ const [exactTypes, setExactTypes] = useQueryParam('exactTypes', BooleanParam);
+
+ const fieldsetStyle = 'w-100 bn';
+ const titleStyle = 'w-100 db ttu fw5 blue-grey';
+
+ const extraFilters = [
+ {
+ fieldsetName: 'campaign',
+ selectedTag: campaignInQuery,
+ options: campaignAPIState,
+ },
+ {
+ fieldsetName: 'organisation',
+ selectedTag: orgInQuery,
+ options: orgAPIState,
+ },
+ {
+ fieldsetName: 'location',
+ selectedTag: countryInQuery,
+ options: countriesAPIState,
+ payloadKey: 'value',
+ },
+ {
+ fieldsetName: 'interests',
+ selectedTag: interestInQuery,
+ options: interestAPIState,
+ payloadKey: 'id',
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/frontend/src/components/projects/myProjectNav.js b/frontend/src/components/projects/myProjectNav.js
deleted file mode 100644
index a7ddee0a47..0000000000
--- a/frontend/src/components/projects/myProjectNav.js
+++ /dev/null
@@ -1,285 +0,0 @@
-import { Link } from 'react-router-dom';
-import { useSelector } from 'react-redux';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { AddButton } from '../teamsAndOrgs/management';
-import { useExploreProjectsQueryParams, stringify } from '../../hooks/UseProjectsQueryAPI';
-import { useFetch } from '../../hooks/UseFetch';
-import { ProjectSearchBox } from './projectSearchBox';
-import ClearFilters from './clearFilters';
-import { ProjectFilterSelect } from './filterSelectFields';
-import { OrderBySelector } from './orderBy';
-import { ShowMapToggle, ProjectListViewToggle } from './projectNav';
-import { CustomButton } from '../button';
-
-export const MyProjectNav = (props) => {
- const userDetails = useSelector((state) => state.auth.userDetails);
- const isOrgManager = useSelector(
- (state) => state.auth.organisations && state.auth.organisations.length > 0,
- );
- const isPMTeamMember = useSelector(
- (state) => state.auth.pmTeams && state.auth.pmTeams.length > 0,
- );
- const [fullProjectsQuery, setQuery] = useExploreProjectsQueryParams();
- const notAnyFilter = !stringify(fullProjectsQuery);
-
- const isActiveButton = (buttonName, projectQuery) =>
- JSON.stringify(projectQuery).indexOf(buttonName) !== -1 ? true : false;
-
- const projectStatusMenus = [
- {
- isActiveArg: 'PUBLISHED',
- label: ,
- queryParams: {
- status: 'PUBLISHED',
- stale: undefined,
- },
- },
- {
- isActiveArg: 'DRAFT',
- label: ,
- queryParams: {
- status: 'DRAFT',
- stale: undefined,
- },
- },
- {
- isActiveArg: 'ARCHIVED',
- label: ,
- queryParams: {
- status: 'ARCHIVED',
- stale: undefined,
- },
- },
- {
- isActiveArg: 'stale',
- label: ,
- queryParams: {
- status: undefined,
- stale: 1,
- },
- },
- ];
-
- return (
-
- );
-};
-
-export function FilterButton({
- currentQuery,
- newQueryParams,
- setQuery,
- isActive,
- children,
-}: Object) {
- const linkCombo = 'di mh1 link ph3 f6 pv2 mv1 ba b--grey-light';
- return (
- setQuery({ ...currentQuery, page: undefined, ...newQueryParams })}
- className={`${isActive ? 'bg-blue-grey white fw5' : 'bg-white blue-grey'} ${linkCombo}`}
- >
- {children}
-
- );
-}
-
-function ManagerFilters({ query, setQuery }: Object) {
- const userDetails = useSelector((state) => state.auth.userDetails);
- const [campaignsError, campaignsLoading, campaigns] = useFetch('campaigns/');
- const [orgsError, orgsLoading, organisations] = useFetch(
- `organisations/?omitManagerList=true${
- userDetails.role === 'ADMIN' ? '' : `&manager_user_id=${userDetails.id}`
- }`,
- userDetails && userDetails.id,
- );
- const { campaign: campaignInQuery, organisation: orgInQuery } = query;
- return (
- <>
- 0 ? campaigns.campaigns : [],
- }}
- setQueryForChild={setQuery}
- allQueryParamsForChild={query}
- />
-
- 0 ? organisations.organisations : [],
- }}
- setQueryForChild={setQuery}
- allQueryParamsForChild={query}
- />
- >
- );
-}
diff --git a/frontend/src/components/projects/myProjectNav.jsx b/frontend/src/components/projects/myProjectNav.jsx
new file mode 100644
index 0000000000..4b1de31a4b
--- /dev/null
+++ b/frontend/src/components/projects/myProjectNav.jsx
@@ -0,0 +1,279 @@
+import { Link } from 'react-router-dom';
+import { useTypedSelector } from '@Store/hooks';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { AddButton } from '../teamsAndOrgs/management';
+import { useExploreProjectsQueryParams, stringify } from '../../hooks/UseProjectsQueryAPI';
+import { useFetch } from '../../hooks/UseFetch';
+import { ProjectSearchBox } from './projectSearchBox';
+import ClearFilters from './clearFilters';
+import { ProjectFilterSelect } from './filterSelectFields';
+import { OrderBySelector } from './orderBy';
+import { ShowMapToggle, ProjectListViewToggle } from './projectNav';
+import { CustomButton } from '../button';
+
+export const MyProjectNav = (props) => {
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const isOrgManager = useTypedSelector(
+ (state) => state.auth.organisations && state.auth.organisations.length > 0,
+ );
+ const isPMTeamMember = useTypedSelector(
+ (state) => state.auth.pmTeams && state.auth.pmTeams.length > 0,
+ );
+ const [fullProjectsQuery, setQuery] = useExploreProjectsQueryParams();
+ const notAnyFilter = !stringify(fullProjectsQuery);
+
+ const isActiveButton = (buttonName, projectQuery) =>
+ JSON.stringify(projectQuery).indexOf(buttonName) !== -1 ? true : false;
+
+ const projectStatusMenus = [
+ {
+ isActiveArg: 'PUBLISHED',
+ label: ,
+ queryParams: {
+ status: 'PUBLISHED',
+ stale: undefined,
+ },
+ },
+ {
+ isActiveArg: 'DRAFT',
+ label: ,
+ queryParams: {
+ status: 'DRAFT',
+ stale: undefined,
+ },
+ },
+ {
+ isActiveArg: 'ARCHIVED',
+ label: ,
+ queryParams: {
+ status: 'ARCHIVED',
+ stale: undefined,
+ },
+ },
+ {
+ isActiveArg: 'stale',
+ label: ,
+ queryParams: {
+ status: undefined,
+ stale: 1,
+ },
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export function FilterButton({ currentQuery, newQueryParams, setQuery, isActive, children }) {
+ const linkCombo = 'di mh1 link ph3 f6 pv2 mv1 ba b--grey-light';
+ return (
+ setQuery({ ...currentQuery, page: undefined, ...newQueryParams })}
+ className={`${isActive ? 'bg-blue-grey white fw5' : 'bg-white blue-grey'} ${linkCombo}`}
+ >
+ {children}
+
+ );
+}
+
+function ManagerFilters({ query, setQuery }) {
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const [campaignsError, campaignsLoading, campaigns] = useFetch('campaigns/');
+ const [orgsError, orgsLoading, organisations] = useFetch(
+ `organisations/?omitManagerList=true${
+ userDetails?.role === 'ADMIN' ? '' : `&manager_user_id=${userDetails?.id}`
+ }`,
+ userDetails && userDetails?.id,
+ );
+ const { campaign: campaignInQuery, organisation: orgInQuery } = query;
+ return (
+ <>
+ 0 ? campaigns.campaigns : [],
+ }}
+ setQueryForChild={setQuery}
+ allQueryParamsForChild={query}
+ />
+
+ 0 ? organisations.organisations : [],
+ }}
+ setQueryForChild={setQuery}
+ allQueryParamsForChild={query}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/projects/orderBy.js b/frontend/src/components/projects/orderBy.jsx
similarity index 100%
rename from frontend/src/components/projects/orderBy.js
rename to frontend/src/components/projects/orderBy.jsx
diff --git a/frontend/src/components/projects/partnersFilterSelect.js b/frontend/src/components/projects/partnersFilterSelect.js
deleted file mode 100644
index b5e831b33e..0000000000
--- a/frontend/src/components/projects/partnersFilterSelect.js
+++ /dev/null
@@ -1,233 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
-import Select from 'react-select';
-import ReactDatePicker from 'react-datepicker';
-import { FormattedMessage } from 'react-intl';
-import PropTypes from 'prop-types';
-
-import { DateCustomInput } from '../projectEdit/partnersForm';
-import { useAllPartnersQuery } from '../../api/projects';
-import messagesFromProjectEdit from '../projectEdit/messages';
-import messages from './messages.js';
-
-export const PartnersFilterSelect = ({
- fieldsetName,
- fieldsetStyle,
- titleStyle,
- queryParams,
- setQueryParams,
-}) => {
- const [selectedPartner, setSelectedPartner] = useState({});
- const [dateRange, setDateRange] = useState({
- startDate: null,
- endDate: null,
- });
- const userDetails = useSelector((state) => state.auth.userDetails);
- const token = useSelector((state) => state.auth.token);
- const { isPending, isError, data: partners } = useAllPartnersQuery(token, userDetails.id);
-
- useEffect(() => {
- if (queryParams.partnerId && partners) {
- for (const partner of partners) {
- if (partner.id === queryParams.partnerId) {
- setSelectedPartner(partner);
- break;
- }
- }
- }
-
- const dateRangeCopy = { ...dateRange };
- if (queryParams.partnershipFrom) {
- dateRangeCopy.startDate = deriveDateObjectFromQueryParam(queryParams.partnershipFrom);
- }
-
- if (queryParams.partnershipTo) {
- dateRangeCopy.endDate = deriveDateObjectFromQueryParam(queryParams.partnershipTo);
- }
- setDateRange({ ...dateRangeCopy });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [queryParams, partners]);
-
-
- const handlePartnerSelection = (value) => {
- const queryParamsCopy = { ...queryParams };
- setSelectedPartner(value || {});
- queryParamsCopy.partnerId = value ? value.id : null;
- if (!value) {
- queryParamsCopy.partnershipFrom = null;
- queryParamsCopy.partnershipTo = null;
- setDateRange({ startDate: null, endDate: null });
- }
-
- setQueryParams(
- {
- ...queryParamsCopy,
- page: undefined,
- },
- 'pushIn',
- );
- };
-
- const deriveDateObjectFromQueryParam = (date) => {
- const [year, month, day] = date.split('-');
- const dateObject = new Date(year, month - 1, day);
- return dateObject;
- };
-
- const getDateString = (date) => {
- const [year, month, day] = [date.getFullYear(), date.getMonth() + 1, date.getDate()];
- const dateString = `${year}-${month}-${day}`;
- return dateString;
- };
-
- const handleDateSelection = (date, isFromDate = true) => {
- if (!isFromDate) {
- setDateRange({
- ...dateRange,
- endDate: date,
- });
- setQueryParams(
- {
- ...queryParams,
- page: undefined,
- partnershipTo: getDateString(date),
- },
- 'pushIn',
- );
- return;
- }
-
- if (dateRange.endDate && dateRange.endDate < date) {
- setDateRange({
- ...dateRange,
- startDate: date,
- endDate: null,
- });
- } else {
- setDateRange({
- ...dateRange,
- startDate: date,
- });
- }
- setQueryParams(
- {
- ...queryParams,
- page: undefined,
- partnershipFrom: getDateString(date),
- },
- 'pushIn',
- );
- };
-
- const handleDateClear = (isFromDate = true) => {
- if (!isFromDate) {
- setDateRange({
- ...dateRange,
- endDate: null,
- });
- setQueryParams(
- {
- ...queryParams,
- page: undefined,
- partnershipTo: null,
- },
- 'pushIn',
- );
- return;
- }
-
- setDateRange({
- ...dateRange,
- startDate: null,
- });
- setQueryParams(
- {
- ...queryParams,
- page: undefined,
- partnershipFrom: null,
- },
- 'pushIn',
- );
- };
-
- return (
-
-
-
-
- option.name}
- getOptionValue={(option) => option.id}
- options={partners}
- value={selectedPartner.id ? selectedPartner : null}
- placeholder={
- isError ? (
-
- ) : (
-
- )
- }
- onChange={handlePartnerSelection}
- styles={{
- menu: (baseStyles) => ({
- ...baseStyles,
- // having greater zIndex than switch toggle(5)
- zIndex: 6,
- }),
- }}
- />
-
- {selectedPartner.id && (
-
-
-
-
-
- handleDateSelection(date)}
- dateFormat="dd/MM/yyyy"
- showYearDropdown
- scrollableYearDropdown
- customInput={
- handleDateClear()}
- placeholderMessage={messages.partnerFromDate}
- />
- }
- />
-
- handleDateSelection(date, false)}
- dateFormat="dd/MM/yyyy"
- minDate={dateRange.startDate ? dateRange.startDate : null}
- showYearDropdown
- scrollableYearDropdown
- customInput={
- handleDateClear(false)}
- placeholderMessage={messages.partnerEndDate}
- />
- }
- />
-
-
- )}
-
- );
-};
-
-PartnersFilterSelect.propTypes = {
- fieldsetName: PropTypes.string.isRequired,
- fieldsetStyle: PropTypes.string.isRequired,
- titleStyle: PropTypes.string.isRequired,
- queryParams: PropTypes.string,
- setQueryParams: PropTypes.func.isRequired,
-};
diff --git a/frontend/src/components/projects/partnersFilterSelect.jsx b/frontend/src/components/projects/partnersFilterSelect.jsx
new file mode 100644
index 0000000000..9c4cf107b6
--- /dev/null
+++ b/frontend/src/components/projects/partnersFilterSelect.jsx
@@ -0,0 +1,233 @@
+import { useEffect, useState } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import Select from 'react-select';
+import ReactDatePicker from 'react-datepicker';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import { DateCustomInput } from '../projectEdit/partnersForm';
+import { useAllPartnersQuery } from '../../api/projects';
+import messagesFromProjectEdit from '../projectEdit/messages';
+import messages from './messages';
+
+export const PartnersFilterSelect = ({
+ fieldsetName,
+ fieldsetStyle,
+ titleStyle,
+ queryParams,
+ setQueryParams,
+}) => {
+ const [selectedPartner, setSelectedPartner] = useState({});
+ const [dateRange, setDateRange] = useState({
+ startDate: null,
+ endDate: null,
+ });
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const token = useTypedSelector((state) => state.auth.token);
+ const { isPending, isError, data: partners } = useAllPartnersQuery(token, userDetails?.id);
+
+ useEffect(() => {
+ if (queryParams.partnerId && partners) {
+ for (const partner of partners) {
+ if (partner.id === queryParams.partnerId) {
+ setSelectedPartner(partner);
+ break;
+ }
+ }
+ }
+
+ const dateRangeCopy = { ...dateRange };
+ if (queryParams.partnershipFrom) {
+ dateRangeCopy.startDate = deriveDateObjectFromQueryParam(queryParams.partnershipFrom);
+ }
+
+ if (queryParams.partnershipTo) {
+ dateRangeCopy.endDate = deriveDateObjectFromQueryParam(queryParams.partnershipTo);
+ }
+ setDateRange({ ...dateRangeCopy });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [queryParams, partners]);
+
+
+ const handlePartnerSelection = (value) => {
+ const queryParamsCopy = { ...queryParams };
+ setSelectedPartner(value || {});
+ queryParamsCopy.partnerId = value ? value.id : null;
+ if (!value) {
+ queryParamsCopy.partnershipFrom = null;
+ queryParamsCopy.partnershipTo = null;
+ setDateRange({ startDate: null, endDate: null });
+ }
+
+ setQueryParams(
+ {
+ ...queryParamsCopy,
+ page: undefined,
+ },
+ 'pushIn',
+ );
+ };
+
+ const deriveDateObjectFromQueryParam = (date) => {
+ const [year, month, day] = date.split('-');
+ const dateObject = new Date(year, month - 1, day);
+ return dateObject;
+ };
+
+ const getDateString = (date) => {
+ const [year, month, day] = [date.getFullYear(), date.getMonth() + 1, date.getDate()];
+ const dateString = `${year}-${month}-${day}`;
+ return dateString;
+ };
+
+ const handleDateSelection = (date, isFromDate = true) => {
+ if (!isFromDate) {
+ setDateRange({
+ ...dateRange,
+ endDate: date,
+ });
+ setQueryParams(
+ {
+ ...queryParams,
+ page: undefined,
+ partnershipTo: getDateString(date),
+ },
+ 'pushIn',
+ );
+ return;
+ }
+
+ if (dateRange.endDate && dateRange.endDate < date) {
+ setDateRange({
+ ...dateRange,
+ startDate: date,
+ endDate: null,
+ });
+ } else {
+ setDateRange({
+ ...dateRange,
+ startDate: date,
+ });
+ }
+ setQueryParams(
+ {
+ ...queryParams,
+ page: undefined,
+ partnershipFrom: getDateString(date),
+ },
+ 'pushIn',
+ );
+ };
+
+ const handleDateClear = (isFromDate = true) => {
+ if (!isFromDate) {
+ setDateRange({
+ ...dateRange,
+ endDate: null,
+ });
+ setQueryParams(
+ {
+ ...queryParams,
+ page: undefined,
+ partnershipTo: null,
+ },
+ 'pushIn',
+ );
+ return;
+ }
+
+ setDateRange({
+ ...dateRange,
+ startDate: null,
+ });
+ setQueryParams(
+ {
+ ...queryParams,
+ page: undefined,
+ partnershipFrom: null,
+ },
+ 'pushIn',
+ );
+ };
+
+ return (
+
+
+
+
+ option.name}
+ getOptionValue={(option) => option.id}
+ options={partners}
+ value={selectedPartner.id ? selectedPartner : null}
+ placeholder={
+ isError ? (
+
+ ) : (
+
+ )
+ }
+ onChange={handlePartnerSelection}
+ styles={{
+ menu: (baseStyles) => ({
+ ...baseStyles,
+ // having greater zIndex than switch toggle(5)
+ zIndex: 6,
+ }),
+ }}
+ />
+
+ {selectedPartner.id && (
+
+
+
+
+
+ handleDateSelection(date)}
+ dateFormat="dd/MM/yyyy"
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+ handleDateClear()}
+ placeholderMessage={messages.partnerFromDate}
+ />
+ }
+ />
+
+ handleDateSelection(date, false)}
+ dateFormat="dd/MM/yyyy"
+ minDate={dateRange.startDate ? dateRange.startDate : null}
+ showYearDropdown
+ scrollableYearDropdown
+ customInput={
+ handleDateClear(false)}
+ placeholderMessage={messages.partnerEndDate}
+ />
+ }
+ />
+
+
+ )}
+
+ );
+};
+
+PartnersFilterSelect.propTypes = {
+ fieldsetName: PropTypes.string.isRequired,
+ fieldsetStyle: PropTypes.string.isRequired,
+ titleStyle: PropTypes.string.isRequired,
+ queryParams: PropTypes.string,
+ setQueryParams: PropTypes.func.isRequired,
+};
diff --git a/frontend/src/components/projects/projectCardPaginator.js b/frontend/src/components/projects/projectCardPaginator.jsx
similarity index 100%
rename from frontend/src/components/projects/projectCardPaginator.js
rename to frontend/src/components/projects/projectCardPaginator.jsx
diff --git a/frontend/src/components/projects/projectNav.js b/frontend/src/components/projects/projectNav.js
deleted file mode 100644
index 5acb85faab..0000000000
--- a/frontend/src/components/projects/projectNav.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import { useEffect } from 'react';
-import { Link, useLocation } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-import { useSelector, useDispatch } from 'react-redux';
-import PropTypes from 'prop-types';
-
-import messages from './messages';
-import { useExploreProjectsQueryParams, stringify } from '../../hooks/UseProjectsQueryAPI';
-import { DifficultyMessage } from '../mappingLevel';
-import { Dropdown } from '../dropdown';
-import { ProjectSearchBox } from './projectSearchBox';
-import ClearFilters from './clearFilters';
-import { OrderBySelector } from './orderBy';
-import { ProjectsActionFilter } from './projectsActionFilter';
-import { SwitchToggle } from '../formInputs';
-import DownloadAsCSV from './downloadAsCSV';
-import { GripIcon, ListIcon, FilledNineCellsGridIcon, TableListIcon } from '../svgIcons';
-
-export const ShowMapToggle = (props) => {
- const dispatch = useDispatch();
- const isMapShown = useSelector((state) => state.preferences['mapShown']);
- const isExploreProjectsTableView = useSelector(
- (state) => state.preferences['isExploreProjectsTableView'],
- );
-
- useEffect(() => {
- if (isExploreProjectsTableView && isMapShown) {
- dispatch({ type: 'TOGGLE_MAP' });
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isExploreProjectsTableView]);
-
- return (
-
- dispatch({ type: 'TOGGLE_MAP' })}
- isChecked={isMapShown}
- label={ }
- isDisabled={isExploreProjectsTableView}
- />
-
- );
-};
-
-export const ProjectListViewToggle = (props) => {
- const dispatch = useDispatch();
- const listViewIsActive = useSelector((state) => state.preferences['projectListView']);
- return (
-
- dispatch({ type: 'TOGGLE_LIST_VIEW' })}
- />
- dispatch({ type: 'TOGGLE_CARD_VIEW' })}
- />
-
- );
-};
-
-const ExploreProjectsViewToggle = () => {
- const dispatch = useDispatch();
- const isExploreProjectsTableView = useSelector(
- (state) => state.preferences['isExploreProjectsTableView'],
- );
-
- return (
- <>
- dispatch({ type: 'SET_EXPLORE_PROJECTS_CARD_VIEW' })}
- />
- dispatch({ type: 'SET_EXPLORE_PROJECTS_TABLE_VIEW' })}
- />
- >
- );
-};
-
-const DifficultyDropdown = (props) => {
- return (
- {
- const value = n && n[0] && n[0].value;
- props.setQuery(
- {
- ...props.fullProjectsQuery,
- page: undefined,
- difficulty: value,
- },
- 'pushIn',
- );
- }}
- value={props.fullProjectsQuery.difficulty || []}
- options={[
- { label: , value: 'ALL' },
- { label: , value: 'EASY' },
- { label: , value: 'MODERATE' },
- { label: , value: 'CHALLENGING' },
- ]}
- display={ }
- className={'ba b--tan bg-white mr3 f6 v-mid dn dib-ns pv2 br1 pl3 fw5 blue-dark'}
- />
- );
-};
-
-export const ProjectNav = ({ isExploreProjectsPage, children }) => {
- const location = useLocation();
- const [fullProjectsQuery, setQuery] = useExploreProjectsQueryParams();
- const encodedParams = stringify(fullProjectsQuery)
- ? ['?', stringify(fullProjectsQuery)].join('')
- : '';
- const isMapShown = useSelector((state) => state.preferences['mapShown']);
- const isExploreProjectsTableView = useSelector(
- (state) => state.preferences['isExploreProjectsTableView'],
- );
-
- useEffect(() => {
- setQuery(
- {
- ...fullProjectsQuery,
- omitMapResults: !isMapShown,
- },
- 'pushIn',
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isMapShown]);
- const linkCombo = 'link ph3 f6 pv2 ba b--tan br1 ph3 fw5';
-
- const moreFiltersAnyActive =
- fullProjectsQuery.organisation ||
- fullProjectsQuery.location ||
- fullProjectsQuery.campaign ||
- fullProjectsQuery.types ||
- fullProjectsQuery.partnerId ||
- fullProjectsQuery.partnershipFrom ||
- fullProjectsQuery.partnershipTo;
- const fullProjectsQueryCopy = { ...fullProjectsQuery };
- delete fullProjectsQueryCopy.omitMapResults;
- const filterIsEmpty = !stringify(fullProjectsQueryCopy);
- const moreFiltersCurrentActiveStyle = moreFiltersAnyActive
- ? 'bg-red white'
- : 'bg-white blue-dark';
- const filterRouteToggled =
- location.pathname.indexOf('filters') > -1
- ? '/explore' + encodedParams
- : './filters/' + encodedParams;
- let clearFiltersURL = './';
- if ((isExploreProjectsPage && isExploreProjectsTableView) || !isMapShown) {
- clearFiltersURL = './?omitMapResults=1';
- }
-
- // onSelectedItemChange={(changes) => console.log(changes)}
- return (
- /* mb1 mb2-ns (removed for map, but now small gap for more-filters) */
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!filterIsEmpty && (
-
- )}
-
-
-
-
-
-
-
- {children}
-
- );
-};
-
-ProjectNav.propTypes = {
- isExploreProjectsPage: PropTypes.bool.isRequired,
- children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
-};
diff --git a/frontend/src/components/projects/projectNav.jsx b/frontend/src/components/projects/projectNav.jsx
new file mode 100644
index 0000000000..4178ee0cd2
--- /dev/null
+++ b/frontend/src/components/projects/projectNav.jsx
@@ -0,0 +1,223 @@
+import { useEffect } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import { useTypedDispatch, useTypedSelector } from '@Store/hooks';
+import PropTypes from 'prop-types';
+
+import messages from './messages';
+import { useExploreProjectsQueryParams, stringify } from '../../hooks/UseProjectsQueryAPI';
+import { DifficultyMessage } from '../mappingLevel';
+import { Dropdown } from '../dropdown';
+import { ProjectSearchBox } from './projectSearchBox';
+import ClearFilters from './clearFilters';
+import { OrderBySelector } from './orderBy';
+import { ProjectsActionFilter } from './projectsActionFilter';
+import { SwitchToggle } from '../formInputs';
+import DownloadAsCSV from './downloadAsCSV';
+import { GripIcon, ListIcon, FilledNineCellsGridIcon, TableListIcon } from '../svgIcons';
+
+export const ShowMapToggle = () => {
+ const dispatch = useTypedDispatch();
+ const isMapShown = useTypedSelector((state) => state.preferences['mapShown']);
+ const isExploreProjectsTableView = useTypedSelector(
+ (state) => state.preferences['isExploreProjectsTableView'],
+ );
+
+ useEffect(() => {
+ if (isExploreProjectsTableView && isMapShown) {
+ dispatch({ type: 'TOGGLE_MAP' });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isExploreProjectsTableView]);
+
+ return (
+
+ dispatch({ type: 'TOGGLE_MAP' })}
+ isChecked={isMapShown}
+ label={ }
+ isDisabled={isExploreProjectsTableView}
+ />
+
+ );
+};
+
+export const ProjectListViewToggle = () => {
+ const dispatch = useTypedDispatch();
+ const listViewIsActive = useTypedSelector((state) => state.preferences['projectListView']);
+ return (
+
+ dispatch({ type: 'TOGGLE_LIST_VIEW' })}
+ />
+ dispatch({ type: 'TOGGLE_CARD_VIEW' })}
+ />
+
+ );
+};
+
+const ExploreProjectsViewToggle = () => {
+ const dispatch = useTypedDispatch();
+ const isExploreProjectsTableView = useTypedSelector(
+ (state) => state.preferences['isExploreProjectsTableView'],
+ );
+
+ return (
+ <>
+ dispatch({ type: 'SET_EXPLORE_PROJECTS_CARD_VIEW' })}
+ />
+ dispatch({ type: 'SET_EXPLORE_PROJECTS_TABLE_VIEW' })}
+ />
+ >
+ );
+};
+
+const DifficultyDropdown = (props) => {
+ return (
+ {
+ const value = n && n[0] && n[0].value;
+ props.setQuery(
+ {
+ ...props.fullProjectsQuery,
+ page: undefined,
+ difficulty: value,
+ },
+ 'pushIn',
+ );
+ }}
+ value={props.fullProjectsQuery.difficulty || []}
+ options={[
+ { label: , value: 'ALL' },
+ { label: , value: 'EASY' },
+ { label: , value: 'MODERATE' },
+ { label: , value: 'CHALLENGING' },
+ ]}
+ display={ }
+ className={'ba b--tan bg-white mr3 f6 v-mid dn dib-ns pv2 br1 pl3 fw5 blue-dark'}
+ />
+ );
+};
+
+export const ProjectNav = ({ isExploreProjectsPage, children }) => {
+ const location = useLocation();
+ const [fullProjectsQuery, setQuery] = useExploreProjectsQueryParams();
+ const encodedParams = stringify(fullProjectsQuery)
+ ? ['?', stringify(fullProjectsQuery)].join('')
+ : '';
+ const isMapShown = useTypedSelector((state) => state.preferences['mapShown']);
+ const isExploreProjectsTableView = useTypedSelector(
+ (state) => state.preferences['isExploreProjectsTableView'],
+ );
+
+ useEffect(() => {
+ setQuery(
+ {
+ ...fullProjectsQuery,
+ omitMapResults: !isMapShown,
+ },
+ 'pushIn',
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isMapShown]);
+ const linkCombo = 'link ph3 f6 pv2 ba b--tan br1 ph3 fw5';
+
+ const moreFiltersAnyActive =
+ fullProjectsQuery.organisation ||
+ fullProjectsQuery.location ||
+ fullProjectsQuery.campaign ||
+ fullProjectsQuery.types ||
+ fullProjectsQuery.partnerId ||
+ fullProjectsQuery.partnershipFrom ||
+ fullProjectsQuery.partnershipTo;
+ const fullProjectsQueryCopy = { ...fullProjectsQuery };
+ delete fullProjectsQueryCopy.omitMapResults;
+ const filterIsEmpty = !stringify(fullProjectsQueryCopy);
+ const moreFiltersCurrentActiveStyle = moreFiltersAnyActive
+ ? 'bg-red white'
+ : 'bg-white blue-dark';
+ const filterRouteToggled =
+ location.pathname.indexOf('filters') > -1
+ ? '/explore' + encodedParams
+ : './filters/' + encodedParams;
+ let clearFiltersURL = './';
+ if ((isExploreProjectsPage && isExploreProjectsTableView) || !isMapShown) {
+ clearFiltersURL = './?omitMapResults=1';
+ }
+
+ // onSelectedItemChange={(changes) => console.log(changes)}
+ return (
+ /* mb1 mb2-ns (removed for map, but now small gap for more-filters) */
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!filterIsEmpty && (
+
+ )}
+
+
+
+
+
+
+
+ {children}
+
+ );
+};
+
+ProjectNav.propTypes = {
+ isExploreProjectsPage: PropTypes.bool.isRequired,
+ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
+};
diff --git a/frontend/src/components/projects/projectSearchBox.js b/frontend/src/components/projects/projectSearchBox.jsx
similarity index 100%
rename from frontend/src/components/projects/projectSearchBox.js
rename to frontend/src/components/projects/projectSearchBox.jsx
diff --git a/frontend/src/components/projects/projectSearchResults.js b/frontend/src/components/projects/projectSearchResults.js
deleted file mode 100644
index 9f03050c9e..0000000000
--- a/frontend/src/components/projects/projectSearchResults.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { useSelector } from 'react-redux';
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-import ReactPlaceholder from 'react-placeholder';
-import 'react-placeholder/lib/reactPlaceholder.css';
-
-import { nCardPlaceholders } from '../projectCard/nCardPlaceholder';
-import { ProjectCard } from '../projectCard/projectCard';
-import messages from './messages';
-import { ProjectListItem } from './list';
-import { ExploreProjectsTable } from './exploreProjectsTable';
-
-export const ProjectSearchResults = ({
- className,
- status,
- projects,
- pagination,
- retryFn,
- management,
- showBottomButtons,
- isExploreProjectsPage = false,
-}) => {
- const listViewIsActive = useSelector((state) => state.preferences['projectListView']);
- const isExploreProjectsTableView = useSelector(
- (state) => state.preferences['isExploreProjectsTableView'],
- );
-
- const cardWidthClass = 'w-100';
- const isShowListView =
- (management && listViewIsActive) || (isExploreProjectsPage && isExploreProjectsTableView);
-
- return (
-
-
- {status === 'loading' && }
- {status === 'success' && (
- ,
- }}
- />
- )}
-
- {status === 'error' && (
-
-
,
- yWord: 'Explore Projects',
- }}
- />
-
- retryFn()}>
-
-
-
-
- )}
- {status !== 'error' && (
-
- {isShowListView ? (
-
- {isExploreProjectsPage ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- )}
-
- );
-};
-
-export const ExploreProjectCards = (props) => {
- if (props.pageOfCards?.length === 0) {
- return null;
- }
- /* cardWidthClass={props.cardWidthClass} as a parameter offers more variability in the size of the cards, set to 'cardWidthNone' disables */
- return props.pageOfCards.map((card, n) => (
-
- ));
-};
-
-export const ExploreProjectList = (props) => {
- if (props.pageOfCards?.length === 0) {
- return null;
- }
- /* cardWidthClass={props.cardWidthClass} as a parameter offers more variability in the size of the cards, set to 'cardWidthNone' disables */
- return props.pageOfCards.map((project, n) => );
-};
diff --git a/frontend/src/components/projects/projectSearchResults.jsx b/frontend/src/components/projects/projectSearchResults.jsx
new file mode 100644
index 0000000000..bbf0255061
--- /dev/null
+++ b/frontend/src/components/projects/projectSearchResults.jsx
@@ -0,0 +1,115 @@
+import { useTypedSelector } from '@Store/hooks';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+import ReactPlaceholder from 'react-placeholder';
+import 'react-placeholder/lib/reactPlaceholder.css';
+
+import { nCardPlaceholders } from '../projectCard/nCardPlaceholder';
+import { ProjectCard } from '../projectCard/projectCard';
+import messages from './messages';
+import { ProjectListItem } from './list';
+import { ExploreProjectsTable } from './exploreProjectsTable';
+
+export const ProjectSearchResults = ({
+ className,
+ status,
+ projects,
+ pagination,
+ retryFn,
+ management,
+ showBottomButtons,
+ isExploreProjectsPage = false,
+}) => {
+ const listViewIsActive = useTypedSelector((state) => state.preferences['projectListView']);
+ const isExploreProjectsTableView = useTypedSelector(
+ (state) => state.preferences['isExploreProjectsTableView'],
+ );
+
+ const cardWidthClass = 'w-100';
+ const isShowListView =
+ (management && listViewIsActive) || (isExploreProjectsPage && isExploreProjectsTableView);
+
+ return (
+
+
+ {status === 'pending' && }
+ {status === 'success' && (
+ ,
+ }}
+ />
+ )}
+
+ {status === 'error' && (
+
+
,
+ yWord: 'Explore Projects',
+ }}
+ />
+
+ retryFn()}>
+
+
+
+
+ )}
+ {status !== 'error' && (
+
+ {isShowListView ? (
+
+ {isExploreProjectsPage ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ );
+};
+
+export const ExploreProjectCards = (props) => {
+ if (props.pageOfCards?.length === 0) {
+ return null;
+ }
+ /* cardWidthClass={props.cardWidthClass} as a parameter offers more variability in the size of the cards, set to 'cardWidthNone' disables */
+ return props.pageOfCards.map((card, n) => (
+
+ ));
+};
+
+export const ExploreProjectList = (props) => {
+ if (props.pageOfCards?.length === 0) {
+ return null;
+ }
+ /* cardWidthClass={props.cardWidthClass} as a parameter offers more variability in the size of the cards, set to 'cardWidthNone' disables */
+ return props.pageOfCards.map((project, n) => );
+};
diff --git a/frontend/src/components/projects/projectsActionFilter.js b/frontend/src/components/projects/projectsActionFilter.js
deleted file mode 100644
index d6bd81536e..0000000000
--- a/frontend/src/components/projects/projectsActionFilter.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useEffect } from 'react';
-import { FormattedMessage } from 'react-intl';
-import { useSelector, useDispatch } from 'react-redux';
-
-import messages from './messages';
-import { Dropdown } from '../dropdown';
-
-export const ProjectsActionFilter = ({ setQuery, fullProjectsQuery }) => {
- const dispatch = useDispatch();
- const action = useSelector((state) => state.preferences.action);
- const userDetails = useSelector((state) => state.auth.userDetails);
-
- useEffect(() => {
- // if action is not set on redux/localStorage,
- // set as 'any' for advanced mappers and 'map' for others
- if (!action || action === 'null') {
- dispatch({
- type: 'SET_ACTION',
- action: userDetails.mappingLevel === 'ADVANCED' ? 'any' : 'map',
- });
- }
- }, [dispatch, action, userDetails.mappingLevel]);
-
- return (
- {
- const value = n && n[0] && n[0].value;
- // clean the action query param if it was set on the URL,
- // as our main source of truth is the redux store
- if (fullProjectsQuery.action) {
- setQuery(
- {
- ...fullProjectsQuery,
- page: undefined,
- action: undefined,
- },
- 'pushIn',
- );
- }
- // 'Archived' is a special case, as it is not a valid action
- dispatch({ type: 'SET_ACTION', action: value === 'ARCHIVED' ? 'any' : value });
- setQuery(
- {
- ...fullProjectsQuery,
- page: undefined,
- status: value !== 'ARCHIVED' ? undefined : 'ARCHIVED',
- },
- 'pushIn',
- );
- }}
- // use the action query param, in case someone loads the page with /explore?action=*
- value={fullProjectsQuery.status || fullProjectsQuery.action || action || 'any'}
- options={[
- { label: , value: 'map' },
- { label: , value: 'validate' },
- { label: , value: 'any' },
- { label: , value: 'ARCHIVED' },
- ]}
- display={'Action'}
- className={'ba b--tan bg-white mr3 f6 v-mid dn dib-ns pv2 br1 pl3 fw5 blue-dark'}
- />
- );
-};
diff --git a/frontend/src/components/projects/projectsActionFilter.jsx b/frontend/src/components/projects/projectsActionFilter.jsx
new file mode 100644
index 0000000000..ce6142d402
--- /dev/null
+++ b/frontend/src/components/projects/projectsActionFilter.jsx
@@ -0,0 +1,62 @@
+import { useEffect } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useTypedDispatch, useTypedSelector } from '@Store/hooks';
+import messages from './messages';
+import { Dropdown } from '../dropdown';
+
+export const ProjectsActionFilter = ({ setQuery, fullProjectsQuery }) => {
+ const dispatch = useTypedDispatch();
+ const action = useTypedSelector((state) => state.preferences.action);
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+
+ useEffect(() => {
+ // if action is not set on redux/localStorage,
+ // set as 'any' for advanced mappers and 'map' for others
+ if (!action || action === 'null') {
+ dispatch({
+ type: 'SET_ACTION',
+ action: userDetails?.mappingLevel === 'ADVANCED' ? 'any' : 'map',
+ });
+ }
+ }, [dispatch, action, userDetails?.mappingLevel]);
+
+ return (
+ {
+ const value = n && n[0] && n[0].value;
+ // clean the action query param if it was set on the URL,
+ // as our main source of truth is the redux store
+ if (fullProjectsQuery.action) {
+ setQuery(
+ {
+ ...fullProjectsQuery,
+ page: undefined,
+ action: undefined,
+ },
+ 'pushIn',
+ );
+ }
+ // 'Archived' is a special case, as it is not a valid action
+ dispatch({ type: 'SET_ACTION', action: value === 'ARCHIVED' ? 'any' : value });
+ setQuery(
+ {
+ ...fullProjectsQuery,
+ page: undefined,
+ status: value !== 'ARCHIVED' ? undefined : 'ARCHIVED',
+ },
+ 'pushIn',
+ );
+ }}
+ // use the action query param, in case someone loads the page with /explore?action=*
+ value={fullProjectsQuery.status || fullProjectsQuery.action || action || 'any'}
+ options={[
+ { label: , value: 'map' },
+ { label: , value: 'validate' },
+ { label: , value: 'any' },
+ { label: , value: 'ARCHIVED' },
+ ]}
+ display={'Action'}
+ className={'ba b--tan bg-white mr3 f6 v-mid dn dib-ns pv2 br1 pl3 fw5 blue-dark'}
+ />
+ );
+};
diff --git a/frontend/src/components/projects/projectsMap.js b/frontend/src/components/projects/projectsMap.js
deleted file mode 100644
index 64f7a7bb6e..0000000000
--- a/frontend/src/components/projects/projectsMap.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import { createRef, useLayoutEffect, useState, useCallback } from 'react';
-import mapboxgl from 'mapbox-gl';
-import 'mapbox-gl/dist/mapbox-gl.css';
-import MapboxLanguage from '@mapbox/mapbox-gl-language';
-
-import WebglUnsupported from '../webglUnsupported';
-import { MAPBOX_TOKEN, MAP_STYLE, MAPBOX_RTL_PLUGIN_URL } from '../../config';
-import mapMarker from '../../assets/img/mapMarker.png';
-import useMapboxSupportedLanguage from '../../hooks/UseMapboxSupportedLanguage';
-
-let markerIcon = new Image(17, 20);
-markerIcon.src = mapMarker;
-
-mapboxgl.accessToken = MAPBOX_TOKEN;
-try {
- mapboxgl.setRTLTextPlugin(MAPBOX_RTL_PLUGIN_URL);
-} catch {
- console.log('RTLTextPlugin is loaded');
-}
-
-const licensedFonts = MAPBOX_TOKEN
- ? ['DIN Offc Pro Medium', 'Arial Unicode MS Bold']
- : ['Open Sans Semibold'];
-
-export const mapboxLayerDefn = (map, mapResults, clickOnProjectID, disablePoiClick = false) => {
- map.addImage('mapMarker', markerIcon, { width: 15, height: 15, data: markerIcon });
- map.addSource('projects', {
- type: 'geojson',
- data: mapResults,
- cluster: true,
- clusterRadius: 35,
- });
-
- map.addLayer({
- id: 'projectsClusters',
- filter: ['has', 'point_count'],
- type: 'circle',
- source: 'projects',
- layout: {},
- paint: {
- 'circle-color': 'rgba(104,112,127,0.5)',
- 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 22, 50, 30, 500, 37],
- },
- });
-
- map.addLayer({
- id: 'cluster-count',
- type: 'symbol',
- source: 'projects',
- filter: ['has', 'point_count'],
- layout: {
- 'text-field': '{point_count_abbreviated}',
- 'text-font': licensedFonts,
- 'text-size': 16,
- },
- paint: {
- 'text-color': '#FFF',
- 'text-halo-width': 10,
- 'text-halo-blur': 1,
- },
- });
-
- map.addLayer({
- id: 'projects-unclustered-points',
- type: 'symbol',
- source: 'projects',
- filter: ['!', ['has', 'point_count']],
- layout: {
- 'icon-image': 'mapMarker',
- 'text-field': '#{projectId}',
- 'text-font': licensedFonts,
- 'text-offset': [0, 0.6],
- 'text-anchor': 'top',
- },
- paint: {
- 'text-color': '#2c3038',
- 'text-halo-width': 1,
- 'text-halo-color': '#fff',
- },
- });
- map.on('mouseenter', 'projects-unclustered-points', function (e) {
- // Change the cursor style as a UI indicator.
- if (!disablePoiClick) {
- map.getCanvas().style.cursor = 'pointer';
- }
- });
- map.on('mouseleave', 'projects-unclustered-points', function (e) {
- // Change the cursor style as a UI indicator.
- map.getCanvas().style.cursor = '';
- });
-
- map.on('click', 'projects-unclustered-points', (e) => {
- const value = e.features && e.features[0].properties && e.features[0].properties.projectId;
- clickOnProjectID(value);
- });
-};
-
-export const ProjectsMap = ({ mapResults, fullProjectsQuery, setQuery, className }) => {
- const mapRef = createRef();
- const [map, setMapObj] = useState(null);
- const mapboxSupportedLanguage = useMapboxSupportedLanguage();
-
- const clickOnProjectID = useCallback(
- (projectIdSearch) =>
- setQuery(
- {
- ...fullProjectsQuery,
- page: undefined,
- text: ['#', projectIdSearch].join(''),
- },
- 'pushIn',
- ),
- [fullProjectsQuery, setQuery],
- );
-
- useLayoutEffect(() => {
- /* May be able to refactor this to just take
- * advantage of useRef instead inside other useLayoutEffect() */
-
- /* List of non-mapbox Glyph names is at
- https://github.com/openmaptiles/fonts/tree/gh-pages/Open%20Sans%20Regular */
-
- /* I referenced this initially https://philipprost.com/how-to-use-mapbox-gl-with-react-functional-component/ */
- mapboxgl.supported() &&
- setMapObj(
- new mapboxgl.Map({
- container: mapRef.current,
- style: MAP_STYLE,
- center: [0, 0],
- zoom: 0.5,
- attributionControl: false,
- })
- .addControl(new mapboxgl.AttributionControl({ compact: false }))
- .addControl(new MapboxLanguage({ defaultLanguage: mapboxSupportedLanguage })),
- );
-
- return () => {
- map && map.remove();
- };
- // eslint-disable-next-line
- }, []);
-
- useLayoutEffect(() => {
- /* docs: https://docs.mapbox.com/mapbox-gl-js/example/cluster/ */
-
- const someResultsReady = mapResults && mapResults.features && mapResults.features.length > 0;
-
- const mapReadyProjectsReady =
- map !== null &&
- map.isStyleLoaded() &&
- map.getSource('projects') === undefined &&
- someResultsReady;
- const projectsReadyMapLoading =
- map !== null &&
- !map.isStyleLoaded() &&
- map.getSource('projects') === undefined &&
- someResultsReady;
-
- /* set up style/sources for the map, either immediately or on base load */
- if (mapReadyProjectsReady) {
- mapboxLayerDefn(map, mapResults, clickOnProjectID);
- } else if (projectsReadyMapLoading) {
- map.on('load', () => mapboxLayerDefn(map, mapResults, clickOnProjectID));
- }
-
- /* refill the source on mapResults changes */
- if (map !== null && map.getSource('projects') !== undefined && someResultsReady) {
- map.getSource('projects').setData(mapResults);
- }
- }, [map, mapResults, clickOnProjectID]);
-
- if (!mapboxgl.supported()) {
- return ;
- } else {
- return
;
- }
-};
diff --git a/frontend/src/components/projects/projectsMap.jsx b/frontend/src/components/projects/projectsMap.jsx
new file mode 100644
index 0000000000..d72a640ac8
--- /dev/null
+++ b/frontend/src/components/projects/projectsMap.jsx
@@ -0,0 +1,177 @@
+import { createRef, useLayoutEffect, useState, useCallback } from 'react';
+import mapboxgl from 'mapbox-gl';
+import 'mapbox-gl/dist/mapbox-gl.css';
+import MapboxLanguage from '@mapbox/mapbox-gl-language';
+
+import WebglUnsupported from '../webglUnsupported';
+import { MAPBOX_TOKEN, MAP_STYLE, MAPBOX_RTL_PLUGIN_URL } from '../../config';
+import mapMarker from '../../assets/img/mapMarker.png';
+import useMapboxSupportedLanguage from '../../hooks/UseMapboxSupportedLanguage';
+
+let markerIcon = new Image(17, 20);
+markerIcon.src = mapMarker;
+
+mapboxgl.accessToken = MAPBOX_TOKEN;
+try {
+ mapboxgl.setRTLTextPlugin(MAPBOX_RTL_PLUGIN_URL);
+} catch {
+ console.log('RTLTextPlugin is loaded');
+}
+
+const licensedFonts = MAPBOX_TOKEN
+ ? ['DIN Offc Pro Medium', 'Arial Unicode MS Bold']
+ : ['Open Sans Semibold'];
+
+export const mapboxLayerDefn = (map, mapResults, clickOnProjectID, disablePoiClick = false) => {
+ map.addImage('mapMarker', markerIcon, { width: 15, height: 15, data: markerIcon });
+ map.addSource('projects', {
+ type: 'geojson',
+ data: mapResults,
+ cluster: true,
+ clusterRadius: 35,
+ });
+
+ map.addLayer({
+ id: 'projectsClusters',
+ filter: ['has', 'point_count'],
+ type: 'circle',
+ source: 'projects',
+ layout: {},
+ paint: {
+ 'circle-color': 'rgba(104,112,127,0.5)',
+ 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 22, 50, 30, 500, 37],
+ },
+ });
+
+ map.addLayer({
+ id: 'cluster-count',
+ type: 'symbol',
+ source: 'projects',
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': '{point_count_abbreviated}',
+ 'text-font': licensedFonts,
+ 'text-size': 16,
+ },
+ paint: {
+ 'text-color': '#FFF',
+ 'text-halo-width': 10,
+ 'text-halo-blur': 1,
+ },
+ });
+
+ map.addLayer({
+ id: 'projects-unclustered-points',
+ type: 'symbol',
+ source: 'projects',
+ filter: ['!', ['has', 'point_count']],
+ layout: {
+ 'icon-image': 'mapMarker',
+ 'text-field': '#{projectId}',
+ 'text-font': licensedFonts,
+ 'text-offset': [0, 0.6],
+ 'text-anchor': 'top',
+ },
+ paint: {
+ 'text-color': '#2c3038',
+ 'text-halo-width': 1,
+ 'text-halo-color': '#fff',
+ },
+ });
+ map.on('mouseenter', 'projects-unclustered-points', function () {
+ // Change the cursor style as a UI indicator.
+ if (!disablePoiClick) {
+ map.getCanvas().style.cursor = 'pointer';
+ }
+ });
+ map.on('mouseleave', 'projects-unclustered-points', function () {
+ // Change the cursor style as a UI indicator.
+ map.getCanvas().style.cursor = '';
+ });
+
+ map.on('click', 'projects-unclustered-points', (e) => {
+ const value = e.features && e.features[0].properties && e.features[0].properties.projectId;
+ clickOnProjectID(value);
+ });
+};
+
+export const ProjectsMap = ({ mapResults, fullProjectsQuery, setQuery, className }) => {
+ const mapRef = createRef();
+ const [map, setMapObj] = useState(null);
+ const mapboxSupportedLanguage = useMapboxSupportedLanguage();
+
+ const clickOnProjectID = useCallback(
+ (projectIdSearch) =>
+ setQuery(
+ {
+ ...fullProjectsQuery,
+ page: undefined,
+ text: ['#', projectIdSearch].join(''),
+ },
+ 'pushIn',
+ ),
+ [fullProjectsQuery, setQuery],
+ );
+
+ useLayoutEffect(() => {
+ /* May be able to refactor this to just take
+ * advantage of useRef instead inside other useLayoutEffect() */
+
+ /* List of non-mapbox Glyph names is at
+ https://github.com/openmaptiles/fonts/tree/gh-pages/Open%20Sans%20Regular */
+
+ /* I referenced this initially https://philipprost.com/how-to-use-mapbox-gl-with-react-functional-component/ */
+ mapboxgl.supported() &&
+ setMapObj(
+ new mapboxgl.Map({
+ container: mapRef.current,
+ style: MAP_STYLE,
+ center: [0, 0],
+ zoom: 0.5,
+ attributionControl: false,
+ })
+ .addControl(new mapboxgl.AttributionControl({ compact: false }))
+ .addControl(new MapboxLanguage({ defaultLanguage: mapboxSupportedLanguage })),
+ );
+
+ return () => {
+ map && map.remove();
+ };
+ // eslint-disable-next-line
+ }, []);
+
+ useLayoutEffect(() => {
+ /* docs: https://docs.mapbox.com/mapbox-gl-js/example/cluster/ */
+
+ const someResultsReady = mapResults && mapResults.features && mapResults.features.length > 0;
+
+ const mapReadyProjectsReady =
+ map !== null &&
+ map.isStyleLoaded() &&
+ map.getSource('projects') === undefined &&
+ someResultsReady;
+ const projectsReadyMapLoading =
+ map !== null &&
+ !map.isStyleLoaded() &&
+ map.getSource('projects') === undefined &&
+ someResultsReady;
+
+ /* set up style/sources for the map, either immediately or on base load */
+ if (mapReadyProjectsReady) {
+ mapboxLayerDefn(map, mapResults, clickOnProjectID);
+ } else if (projectsReadyMapLoading) {
+ map.on('load', () => mapboxLayerDefn(map, mapResults, clickOnProjectID));
+ }
+
+ /* refill the source on mapResults changes */
+ if (map !== null && map.getSource('projects') !== undefined && someResultsReady) {
+ map.getSource('projects').setData(mapResults);
+ }
+ }, [map, mapResults, clickOnProjectID]);
+
+ if (!mapboxgl.supported()) {
+ return ;
+ } else {
+ return
;
+ }
+};
diff --git a/frontend/src/components/projects/tests/clearFilters.test.js b/frontend/src/components/projects/tests/clearFilters.test.js
deleted file mode 100644
index fd6d17c3b0..0000000000
--- a/frontend/src/components/projects/tests/clearFilters.test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import ClearFilters from '../clearFilters';
-import { createComponentWithIntl } from '../../../utils/testWithIntl';
-import { MemoryRouter } from 'react-router-dom';
-
-describe('ClearFilters basic properties', () => {
- const element = createComponentWithIntl(
-
-
- ,
- );
- const testInstance = element.root;
- it('is a link and point to the correct place', () => {
- expect(testInstance.findByType('a').props.href).toBe('/explore');
- expect(testInstance.findByType('a').children[0].props.id).toBe('project.nav.clearFilters');
- });
- it('has a FormattedMessage children with the correct id', () => {
- expect(testInstance.findByType('a').children[0].type).toBe(FormattedMessage);
- expect(testInstance.findByType('a').children[0].props.id).toBe('project.nav.clearFilters');
- });
- it('has the correct className', () => {
- expect(testInstance.findByType('a').props.className).toBe('red link ph3 pv2 f6 ');
- const element2 = createComponentWithIntl(
-
-
- ,
- );
- const testInstance2 = element2.root;
- expect(testInstance2.findByType('a').props.className).toBe('red link ph3 pv2 f6 dib mt2');
- });
-});
diff --git a/frontend/src/components/projects/tests/clearFilters.test.jsx b/frontend/src/components/projects/tests/clearFilters.test.jsx
new file mode 100644
index 0000000000..1fb43987d2
--- /dev/null
+++ b/frontend/src/components/projects/tests/clearFilters.test.jsx
@@ -0,0 +1,33 @@
+import ClearFilters from '../clearFilters';
+import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { cleanup, screen } from '@testing-library/react';
+import messages from '../../contributions/messages';
+
+describe('ClearFilters basic properties', () => {
+ beforeEach(() =>
+ renderWithRouter(
+
+
+ ,
+ ),
+ );
+ it('is a link and point to the correct place', async () => {
+ expect(await screen.findByRole('link')).toBeInTheDocument();
+ expect(await screen.findByRole('link')).toHaveAttribute('href', '/explore');
+ });
+ it('has a FormattedMessage children with the correct id', async () => {
+ expect(await screen.findByText(messages.clearFilters.defaultMessage)).toBeInTheDocument();
+ });
+ it('has the correct className', async () => {
+ expect((await screen.findByRole('link')).className).toBe('red link ph3 pv2 f6 ');
+ });
+ it('has the correct className when given additional className props', async () => {
+ cleanup();
+ renderWithRouter(
+
+
+ ,
+ );
+ expect((await screen.findByRole('link')).className).toBe('red link ph3 pv2 f6 dib mt2');
+ });
+});
diff --git a/frontend/src/components/projects/tests/filterSelectFields.test.js b/frontend/src/components/projects/tests/filterSelectFields.test.js
deleted file mode 100644
index 89c3238c11..0000000000
--- a/frontend/src/components/projects/tests/filterSelectFields.test.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import '@testing-library/jest-dom';
-import userEvent from '@testing-library/user-event';
-import { render, screen } from '@testing-library/react';
-import { startOfWeek, startOfYear, format } from 'date-fns';
-
-import { createComponentWithReduxAndIntl, IntlProviders } from '../../../utils/testWithIntl';
-import { DateFilterPicker, DateRangeFilterSelect } from '../filterSelectFields';
-
-describe('tests for selecting date range filters for not custom date ranges', () => {
- const element = createComponentWithReduxAndIntl(
- ,
- );
- const instance = element.root;
- it('has the passed classname for fieldset', () => {
- expect(instance.findByType('fieldset').props.className).toEqual(
- 'bn dib pv0-ns pv2 ph2-ns ph1 mh0 mb1 w-30-ns w-100',
- );
- });
-
- it('should render six options if the date is not custom input', () => {
- expect(instance.findByProps({ classNamePrefix: 'react-select' }).props.options.length).toEqual(
- 6,
- );
- });
-
- it("should set the default dropdown value to 'thisYear'", () => {
- expect(instance.findByProps({ classNamePrefix: 'react-select' }).props.value[0].value).toEqual(
- 'thisYear',
- );
- });
-});
-
-describe('DateRangeFilterSelect', () => {
- it('should set query when an option is selected', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('combobox'));
- await user.click(screen.getByText(/this week/i));
- expect(screen.getByText('This week')).toBeInTheDocument();
- });
-
- it('should set default range by dates in query', async () => {
- render(
-
-
- ,
- );
- expect(screen.getByText('This week')).toBeInTheDocument();
- });
-
- it('should set dropdown option to custom range', async () => {
- render(
-
-
- ,
- );
- expect(screen.getByText(/custom/i)).toBeInTheDocument();
- });
-});
-
-test('DateFilterPicker', async () => {
- const setQueryForChildMock = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- const textbox = screen.getByRole('textbox');
- await user.clear(textbox);
- await user.type(textbox, '2022-02-22');
- expect(setQueryForChildMock).toHaveBeenCalled();
-});
diff --git a/frontend/src/components/projects/tests/filterSelectFields.test.jsx b/frontend/src/components/projects/tests/filterSelectFields.test.jsx
new file mode 100644
index 0000000000..e319692040
--- /dev/null
+++ b/frontend/src/components/projects/tests/filterSelectFields.test.jsx
@@ -0,0 +1,103 @@
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import { startOfWeek, startOfYear, format } from 'date-fns';
+
+import { IntlProviders, ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { DateFilterPicker, DateRangeFilterSelect } from '../filterSelectFields';
+
+describe('tests for selecting date range filters for not custom date ranges', () => {
+ const setup = () =>
+ renderWithRouter(
+
+
+ ,
+ );
+ it('has the passed classname for fieldset', () => {
+ const { container } = setup();
+ expect(container.querySelector('fieldset').className).toEqual(
+ 'bn dib pv0-ns pv2 ph2-ns ph1 mh0 mb1 w-30-ns w-100',
+ );
+ });
+
+ it('should render six options if the date is not custom input', () => {
+ screen.debug();
+ // expect(instance.findByProps({ classNamePrefix: 'react-select' }).props.options.length).toEqual(
+ // 6,
+ // );
+ });
+
+ it("should set the default dropdown value to 'thisYear'", () => {
+ // expect(instance.findByProps({ classNamePrefix: 'react-select' }).props.value[0].value).toEqual(
+ // 'thisYear',
+ // );
+ });
+});
+
+describe('DateRangeFilterSelect', () => {
+ it('should set query when an option is selected', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByText(/this week/i));
+ expect(screen.getByText('This week')).toBeInTheDocument();
+ });
+
+ it('should set default range by dates in query', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('This week')).toBeInTheDocument();
+ });
+
+ it('should set dropdown option to custom range', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/custom/i)).toBeInTheDocument();
+ });
+});
+
+test('DateFilterPicker', async () => {
+ const setQueryForChildMock = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const textbox = screen.getByRole('textbox');
+ await user.clear(textbox);
+ await user.type(textbox, '2022-02-22');
+ expect(setQueryForChildMock).toHaveBeenCalled();
+});
diff --git a/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.js b/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.js
deleted file mode 100644
index fabfa384d4..0000000000
--- a/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import '@testing-library/jest-dom';
-import { MemoryRouter } from 'react-router-dom';
-import { screen } from '@testing-library/react';
-import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
-
-import { QueryParamProvider } from 'use-query-params';
-
-import {
- createComponentWithIntl,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { MappingTypeFilterPicker } from '../mappingTypeFilterPicker';
-import { RoadIcon } from '../../svgIcons';
-
-it('mapping type options show the road icon', () => {
- const filtersForm = createComponentWithIntl(
-
-
- ,
- );
- const testInstance = filtersForm.root;
- // RoadIcon is present because mapping types is rendered
- expect(() => testInstance.findByType(RoadIcon)).not.toThrow(
- new Error('No instances found with node type: "RoadIcon"'),
- );
-});
-
-it('should set query for the clicked icon', async () => {
- const setMappingTypesQueryMock = jest.fn();
- const { user } = renderWithRouter(
-
-
-
-
- ,
- );
- await user.click(
- screen.getByRole('checkbox', {
- name: /roads/i,
- }),
- );
- expect(setMappingTypesQueryMock).toHaveBeenCalledWith(['ROADS'], 'pushIn');
-});
-
-it('should highlight active selected map icon', async () => {
- const setMappingTypesQueryMock = jest.fn();
- renderWithRouter(
-
-
-
-
- ,
- );
- expect(screen.getByTitle('roads')).toHaveClass('blue-dark');
- expect(screen.getByTitle('buildings')).not.toHaveClass('blue-dark');
-});
-
-it('should concatinate values with the present query', async () => {
- const setMappingTypesQueryMock = jest.fn();
- const { user } = renderWithRouter(
-
-
-
-
- ,
- );
-
- await user.click(
- screen.getByRole('checkbox', {
- name: /waterways/i,
- }),
- );
- expect(setMappingTypesQueryMock).toHaveBeenCalledWith(
- ['ROADS', 'BUILDINGS', 'WATERWAYS'],
- 'pushIn',
- );
-});
-
-it('should deselect from the query value', async () => {
- const setMappingTypesQueryMock = jest.fn();
- const { user } = renderWithRouter(
-
-
-
-
- ,
- );
-
- await user.click(
- screen.getByRole('checkbox', {
- name: /roads/i,
- }),
- );
- expect(setMappingTypesQueryMock).toHaveBeenCalledWith(['BUILDINGS'], 'pushIn');
-});
diff --git a/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.jsx b/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.jsx
new file mode 100644
index 0000000000..49c133c580
--- /dev/null
+++ b/frontend/src/components/projects/tests/mappingTypeFilterPicker.test.jsx
@@ -0,0 +1,105 @@
+
+import { MemoryRouter } from 'react-router-dom';
+import { screen } from '@testing-library/react';
+import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
+
+import { QueryParamProvider } from 'use-query-params';
+
+import {
+ createComponentWithIntl,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { MappingTypeFilterPicker } from '../mappingTypeFilterPicker';
+import { RoadIcon } from '../../svgIcons';
+
+it('mapping type options show the road icon', () => {
+ const filtersForm = createComponentWithIntl(
+
+
+ ,
+ );
+ const testInstance = filtersForm.root;
+ // RoadIcon is present because mapping types is rendered
+ expect(() => testInstance.findByType(RoadIcon)).not.toThrow(
+ new Error('No instances found with node type: "RoadIcon"'),
+ );
+});
+
+it('should set query for the clicked icon', async () => {
+ const setMappingTypesQueryMock = vi.fn();
+ const { user } = renderWithRouter(
+
+
+
+
+ ,
+ );
+ await user.click(
+ screen.getByRole('checkbox', {
+ name: /roads/i,
+ }),
+ );
+ expect(setMappingTypesQueryMock).toHaveBeenCalledWith(['ROADS'], 'pushIn');
+});
+
+it('should highlight active selected map icon', async () => {
+ const setMappingTypesQueryMock = vi.fn();
+ renderWithRouter(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTitle('roads')).toHaveClass('blue-dark');
+ expect(screen.getByTitle('buildings')).not.toHaveClass('blue-dark');
+});
+
+it('should concatinate values with the present query', async () => {
+ const setMappingTypesQueryMock = vi.fn();
+ const { user } = renderWithRouter(
+
+
+
+
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('checkbox', {
+ name: /waterways/i,
+ }),
+ );
+ expect(setMappingTypesQueryMock).toHaveBeenCalledWith(
+ ['ROADS', 'BUILDINGS', 'WATERWAYS'],
+ 'pushIn',
+ );
+});
+
+it('should deselect from the query value', async () => {
+ const setMappingTypesQueryMock = vi.fn();
+ const { user } = renderWithRouter(
+
+
+
+
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('checkbox', {
+ name: /roads/i,
+ }),
+ );
+ expect(setMappingTypesQueryMock).toHaveBeenCalledWith(['BUILDINGS'], 'pushIn');
+});
diff --git a/frontend/src/components/projects/tests/moreFiltersForm.test.js b/frontend/src/components/projects/tests/moreFiltersForm.test.js
deleted file mode 100644
index b0bf72ff02..0000000000
--- a/frontend/src/components/projects/tests/moreFiltersForm.test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import '@testing-library/jest-dom';
-import queryString from 'query-string';
-import { act, render, screen, waitFor } from '@testing-library/react';
-import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
-
-import { BooleanParam, decodeQueryParams, QueryParamProvider } from 'use-query-params';
-
-import { store } from '../../../store';
-import {
- createComponentWithMemoryRouter,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { MoreFiltersForm } from '../moreFiltersForm';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-
-describe('MoreFiltersForm', () => {
- it('should not display toggle to filter by user interests if not logged in', async () => {
- act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: null });
- });
- const { user, container } = renderWithRouter(
-
-
-
-
- ,
- );
- await user.click(container.querySelector('#organisation > div > div'));
- await screen.findByText('American Red Cross');
- expect(screen.queryByLabelText('filter by user interests')).not.toBeInTheDocument();
- });
-
- it('should toggle filter by user interests', async () => {
- act(() => {
- store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
- });
- const { user, router } = createComponentWithMemoryRouter(
-
-
-
-
- ,
- );
- const switchControl = screen.getAllByRole('checkbox').slice(-1)[0];
-
- expect(switchControl).toBeInTheDocument();
- await user.click(switchControl);
- await waitFor(() =>
- expect(
- decodeQueryParams(
- {
- basedOnMyInterests: BooleanParam,
- },
- queryString.parse(router.state.location.search),
- ),
- ).toEqual({ basedOnMyInterests: true }),
- );
- });
-
- it('should clear toggle by user interests filter', async () => {
- render(
-
-
-
-
-
-
-
- }
- />
-
- ,
- );
- const switchControl = screen.getAllByRole('checkbox').slice(-1)[0];
- expect(switchControl).toBeChecked();
- });
-});
diff --git a/frontend/src/components/projects/tests/moreFiltersForm.test.jsx b/frontend/src/components/projects/tests/moreFiltersForm.test.jsx
new file mode 100644
index 0000000000..029ba4e4d0
--- /dev/null
+++ b/frontend/src/components/projects/tests/moreFiltersForm.test.jsx
@@ -0,0 +1,81 @@
+
+import queryString from 'query-string';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
+
+import { BooleanParam, decodeQueryParams, QueryParamProvider } from 'use-query-params';
+
+import { store } from '../../../store';
+import {
+ createComponentWithMemoryRouter,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { MoreFiltersForm } from '../moreFiltersForm';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+describe('MoreFiltersForm', () => {
+ it('should not display toggle to filter by user interests if not logged in', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: null });
+ });
+ const { user, container } = renderWithRouter(
+
+
+
+
+ ,
+ );
+ await user.click(container.querySelector('#organisation > div > div'));
+ await screen.findByText('American Red Cross');
+ expect(screen.queryByLabelText('filter by user interests')).not.toBeInTheDocument();
+ });
+
+ it('should toggle filter by user interests', async () => {
+ act(() => {
+ store.dispatch({ type: 'SET_TOKEN', token: 'validToken' });
+ });
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+
+
+ ,
+ );
+ const switchControl = screen.getAllByRole('checkbox').slice(-1)[0];
+
+ expect(switchControl).toBeInTheDocument();
+ await user.click(switchControl);
+ await waitFor(() =>
+ expect(
+ decodeQueryParams(
+ {
+ basedOnMyInterests: BooleanParam,
+ },
+ queryString.parse(router.state.location.search),
+ ),
+ ).toEqual({ basedOnMyInterests: true }),
+ );
+ });
+
+ it('should clear toggle by user interests filter', async () => {
+ render(
+
+
+
+
+
+
+
+ }
+ />
+
+ ,
+ );
+ const switchControl = screen.getAllByRole('checkbox').slice(-1)[0];
+ expect(switchControl).toBeChecked();
+ });
+});
diff --git a/frontend/src/components/projects/tests/myProjectNav.test.js b/frontend/src/components/projects/tests/myProjectNav.test.js
deleted file mode 100644
index 07feef96fe..0000000000
--- a/frontend/src/components/projects/tests/myProjectNav.test.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import '@testing-library/jest-dom';
-import userEvent from '@testing-library/user-event';
-import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
-import { QueryParamProvider } from 'use-query-params';
-import { act, render, screen, waitFor } from '@testing-library/react';
-
-import { MyProjectNav, FilterButton } from '../myProjectNav';
-import {
- createComponentWithMemoryRouter,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { store } from '../../../store';
-
-describe('Manage Projects Top Navigation Bar', () => {
- it('should hide inaccessible components for mappers', () => {
- act(() => {
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { username: 'test', role: 'MAPPER' },
- });
- });
-
- const { container } = renderWithRouter(
-
-
-
-
- ,
- );
-
- expect(
- screen.getByRole('heading', {
- name: /my projects/i,
- }),
- ).toBeInTheDocument();
- expect(
- screen.queryByRole('button', {
- name: /new/i,
- }),
- ).not.toBeInTheDocument();
- expect(screen.queryAllByRole('combobox').length).toBe(0);
- // Check for SVGs for dropdowns and list/vard view toggle
- expect(container.querySelectorAll('svg').length).toBe(2);
- expect(screen.getByRole('textbox')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /sort by/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /contributed/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /favorited/i })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /managed by me/i })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /created by me/i })).toBeInTheDocument();
- expect(screen.getByRole('checkbox')).toBeInTheDocument();
- expect(screen.queryByRole('graphics-symbol')).not.toBeInTheDocument();
- });
-
- it('should render correct details for management view', () => {
- act(() => {
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { username: 'test', role: 'ADMIN' },
- });
- });
-
- const { container } = renderWithRouter(
-
-
-
-
- ,
- );
-
- expect(
- screen.getByRole('heading', {
- name: /manage projects/i,
- }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', {
- name: /new/i,
- }),
- ).toBeInTheDocument();
- expect(screen.getAllByRole('combobox').length).toBe(2);
- expect(screen.queryByRole('button', { name: /contributed/i })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /favorited/i })).not.toBeInTheDocument();
- expect(container.querySelectorAll('svg').length).toBe(7);
- expect(screen.getAllByRole('graphics-symbol').length).toBe(2);
- });
-
- it('should navigate to new project creation page on button click', async () => {
- const { user, router } = createComponentWithMemoryRouter(
-
-
-
-
- ,
- );
-
- await user.click(
- screen.queryByRole('button', {
- name: /new/i,
- }),
- );
- await waitFor(() => expect(router.state.location.pathname).toBe('/manage/projects/new/'));
- });
-});
-
-describe('Filter Button Component', () => {
- it('should display correct classes for active buttons', () => {
- render(Click me! );
- expect(screen.getByText(/click me!/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /click me!/i }).getAttribute('class')).toMatch(
- 'bg-blue-grey white fw5',
- );
- });
-
- it('should display correct classes for inactive buttons', () => {
- render(Click me! );
- expect(screen.getByRole('button', { name: /click me!/i }).getAttribute('class')).toMatch(
- 'bg-white blue-grey',
- );
- });
-
- it('should set query when clicked', async () => {
- const setQueryMock = jest.fn();
- const user = userEvent.setup();
- render(
-
- Click me!
- ,
- );
-
- await user.click(screen.getByRole('button', { name: /click me!/i }));
- expect(setQueryMock).toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/components/projects/tests/myProjectNav.test.jsx b/frontend/src/components/projects/tests/myProjectNav.test.jsx
new file mode 100644
index 0000000000..d3f551a7e7
--- /dev/null
+++ b/frontend/src/components/projects/tests/myProjectNav.test.jsx
@@ -0,0 +1,134 @@
+
+import userEvent from '@testing-library/user-event';
+import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
+import { QueryParamProvider } from 'use-query-params';
+import { act, render, screen, waitFor } from '@testing-library/react';
+
+import { MyProjectNav, FilterButton } from '../myProjectNav';
+import {
+ createComponentWithMemoryRouter,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { store } from '../../../store';
+
+describe('Manage Projects Top Navigation Bar', () => {
+ it('should hide inaccessible components for mappers', () => {
+ act(() => {
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { username: 'test', role: 'MAPPER' },
+ });
+ });
+
+ const { container } = renderWithRouter(
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('heading', {
+ name: /my projects/i,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', {
+ name: /new/i,
+ }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryAllByRole('combobox').length).toBe(0);
+ // Check for SVGs for dropdowns and list/vard view toggle
+ expect(container.querySelectorAll('svg').length).toBe(2);
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /sort by/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /contributed/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /favorited/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /managed by me/i })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /created by me/i })).toBeInTheDocument();
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
+ expect(screen.queryByRole('graphics-symbol')).not.toBeInTheDocument();
+ });
+
+ it('should render correct details for management view', () => {
+ act(() => {
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { username: 'test', role: 'ADMIN' },
+ });
+ });
+
+ const { container } = renderWithRouter(
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('heading', {
+ name: /manage projects/i,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: /new/i,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole('combobox').length).toBe(2);
+ expect(screen.queryByRole('button', { name: /contributed/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /favorited/i })).not.toBeInTheDocument();
+ expect(container.querySelectorAll('svg').length).toBe(7);
+ expect(screen.getAllByRole('graphics-symbol').length).toBe(2);
+ });
+
+ it('should navigate to new project creation page on button click', async () => {
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+
+
+ ,
+ );
+
+ await user.click(
+ screen.queryByRole('button', {
+ name: /new/i,
+ }),
+ );
+ await waitFor(() => expect(router.state.location.pathname).toBe('/manage/projects/new/'));
+ });
+});
+
+describe('Filter Button Component', () => {
+ it('should display correct classes for active buttons', () => {
+ render(Click me! );
+ expect(screen.getByText(/click me!/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /click me!/i }).getAttribute('class')).toMatch(
+ 'bg-blue-grey white fw5',
+ );
+ });
+
+ it('should display correct classes for inactive buttons', () => {
+ render(Click me! );
+ expect(screen.getByRole('button', { name: /click me!/i }).getAttribute('class')).toMatch(
+ 'bg-white blue-grey',
+ );
+ });
+
+ it('should set query when clicked', async () => {
+ const setQueryMock = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ Click me!
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: /click me!/i }));
+ expect(setQueryMock).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/projects/tests/orderBy.test.js b/frontend/src/components/projects/tests/orderBy.test.js
deleted file mode 100644
index b1148ea204..0000000000
--- a/frontend/src/components/projects/tests/orderBy.test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { OrderBySelector } from '../orderBy';
-
-test('should select option on click', async () => {
- const setQueryMock = jest.fn();
- const { user } = renderWithRouter(
-
-
- ,
- );
- await user.click(
- screen.getByRole('button', {
- name: /sort by/i,
- }),
- );
- await user.click(screen.getByText(/urgent projects/i));
- expect(setQueryMock).toHaveBeenCalledWith(
- expect.objectContaining({
- orderBy: 'priority',
- orderByType: 'ASC',
- page: undefined,
- }),
- 'pushIn',
- );
-});
diff --git a/frontend/src/components/projects/tests/orderBy.test.jsx b/frontend/src/components/projects/tests/orderBy.test.jsx
new file mode 100644
index 0000000000..ac54be6e83
--- /dev/null
+++ b/frontend/src/components/projects/tests/orderBy.test.jsx
@@ -0,0 +1,34 @@
+
+import { screen } from '@testing-library/react';
+
+import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { OrderBySelector } from '../orderBy';
+
+test('should select option on click', async () => {
+ const setQueryMock = vi.fn();
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: /sort by/i,
+ }),
+ );
+ await user.click(screen.getByText(/urgent projects/i));
+ expect(setQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orderBy: 'priority',
+ orderByType: 'ASC',
+ page: undefined,
+ }),
+ 'pushIn',
+ );
+});
diff --git a/frontend/src/components/projects/tests/projectCardPaginator.test.js b/frontend/src/components/projects/tests/projectCardPaginator.test.js
deleted file mode 100644
index c000e3cdce..0000000000
--- a/frontend/src/components/projects/tests/projectCardPaginator.test.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-
-import { ProjectCardPaginator } from '../projectCardPaginator';
-
-describe('ProjectCardPaginator Component', () => {
- const setQueryParamMock = jest.fn();
- it('shows the pagination controls', () => {
- render(
- ,
- );
- expect(screen.getAllByRole('button').length).toEqual(3);
- });
-
- it('should set query on the button click', async () => {
- const user = userEvent.setup();
- render(
- ,
- );
- await user.click(
- screen.getByRole('button', {
- name: '2',
- }),
- );
- expect(setQueryParamMock).toHaveBeenCalled();
- });
-
- it('should render nothing if no pagination detail is provided', async () => {
- const { container } = render(
- ,
- );
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/frontend/src/components/projects/tests/projectCardPaginator.test.jsx b/frontend/src/components/projects/tests/projectCardPaginator.test.jsx
new file mode 100644
index 0000000000..f3b67c939c
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectCardPaginator.test.jsx
@@ -0,0 +1,58 @@
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ProjectCardPaginator } from '../projectCardPaginator';
+
+describe('ProjectCardPaginator Component', () => {
+ const setQueryParamMock = vi.fn();
+ it('shows the pagination controls', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByRole('button').length).toEqual(3);
+ });
+
+ it('should set query on the button click', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: '2',
+ }),
+ );
+ expect(setQueryParamMock).toHaveBeenCalled();
+ });
+
+ it('should render nothing if no pagination detail is provided', async () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/frontend/src/components/projects/tests/projectNav.test.js b/frontend/src/components/projects/tests/projectNav.test.js
deleted file mode 100644
index 9c40535d6e..0000000000
--- a/frontend/src/components/projects/tests/projectNav.test.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
-import { QueryParamProvider } from 'use-query-params';
-import { decodeQueryParams, StringParam } from 'serialize-query-params';
-
-import {
- createComponentWithMemoryRouter,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { ProjectNav } from '../projectNav';
-import messages from '../messages';
-import queryString from 'query-string';
-
-describe('Project Navigation Bar', () => {
- it('should render component details', () => {
- renderWithRouter(
-
-
-
-
- ,
- );
-
- expect(
- screen.getByRole('button', {
- name: messages.mappingDifficulty.defaultMessage,
- }),
- ).toBeInTheDocument();
- });
-
- it('should display the clear filters button', async () => {
- const { router } = createComponentWithMemoryRouter(
-
-
-
-
- ,
- { route: '?text=something' },
- );
-
- expect(
- decodeQueryParams({ text: StringParam }, queryString.parse(router.state.location.search)),
- ).toEqual({
- text: 'something',
- });
- });
-});
diff --git a/frontend/src/components/projects/tests/projectNav.test.jsx b/frontend/src/components/projects/tests/projectNav.test.jsx
new file mode 100644
index 0000000000..b7255652fc
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectNav.test.jsx
@@ -0,0 +1,49 @@
+
+import { screen } from '@testing-library/react';
+import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
+import { QueryParamProvider } from 'use-query-params';
+import { decodeQueryParams, StringParam } from 'serialize-query-params';
+
+import {
+ createComponentWithMemoryRouter,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { ProjectNav } from '../projectNav';
+import messages from '../messages';
+import queryString from 'query-string';
+
+describe('Project Navigation Bar', () => {
+ it('should render component details', () => {
+ renderWithRouter(
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: messages.mappingDifficulty.defaultMessage,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('should display the clear filters button', async () => {
+ const { router } = createComponentWithMemoryRouter(
+
+
+
+
+ ,
+ { route: '?text=something' },
+ );
+
+ expect(
+ decodeQueryParams({ text: StringParam }, queryString.parse(router.state.location.search)),
+ ).toEqual({
+ text: 'something',
+ });
+ });
+});
diff --git a/frontend/src/components/projects/tests/projectSearchBox.test.js b/frontend/src/components/projects/tests/projectSearchBox.test.js
deleted file mode 100644
index ed2360b107..0000000000
--- a/frontend/src/components/projects/tests/projectSearchBox.test.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-
-import { IntlProviders } from '../../../utils/testWithIntl';
-import { ProjectSearchBox } from '../projectSearchBox';
-
-describe('ProjectSearchBox', () => {
- it('should set the query as the state', async () => {
- const setQueryMock = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- const textfield = screen.getByRole('textbox');
- await user.type(textfield, 'something');
- expect(setQueryMock).toHaveBeenCalled();
- });
-
- it('should clear the query when the close icon is clicked', async () => {
- const setQueryMock = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- await user.click(screen.getByRole('button'));
- expect(setQueryMock).toHaveBeenCalledWith(
- expect.objectContaining({
- text: undefined,
- page: undefined,
- }),
- 'pushIn',
- );
- });
-
- it('should focus the textbox when search icon is clicked', async () => {
- const setQueryMock = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- const textfield = screen.getByRole('textbox');
- expect(textfield).not.toHaveFocus();
- await user.click(screen.getByLabelText('Search'));
- expect(textfield).toHaveFocus();
- });
-});
diff --git a/frontend/src/components/projects/tests/projectSearchBox.test.jsx b/frontend/src/components/projects/tests/projectSearchBox.test.jsx
new file mode 100644
index 0000000000..40cc38f772
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectSearchBox.test.jsx
@@ -0,0 +1,53 @@
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { IntlProviders } from '../../../utils/testWithIntl';
+import { ProjectSearchBox } from '../projectSearchBox';
+
+describe('ProjectSearchBox', () => {
+ it('should set the query as the state', async () => {
+ const setQueryMock = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const textfield = screen.getByRole('textbox');
+ await user.type(textfield, 'something');
+ expect(setQueryMock).toHaveBeenCalled();
+ });
+
+ it('should clear the query when the close icon is clicked', async () => {
+ const setQueryMock = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole('button'));
+ expect(setQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: undefined,
+ page: undefined,
+ }),
+ 'pushIn',
+ );
+ });
+
+ it('should focus the textbox when search icon is clicked', async () => {
+ const setQueryMock = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const textfield = screen.getByRole('textbox');
+ expect(textfield).not.toHaveFocus();
+ await user.click(screen.getByLabelText('Search'));
+ expect(textfield).toHaveFocus();
+ });
+});
diff --git a/frontend/src/components/projects/tests/projectSearchResults.test.js b/frontend/src/components/projects/tests/projectSearchResults.test.js
deleted file mode 100644
index 11920f4e43..0000000000
--- a/frontend/src/components/projects/tests/projectSearchResults.test.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen, act } from '@testing-library/react';
-
-import { ReduxIntlProviders, IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import {
- ExploreProjectCards,
- ExploreProjectList,
- ProjectSearchResults,
-} from '../projectSearchResults';
-import { projects } from '../../../network/tests/mockData/projects';
-import { store } from '../../../store';
-
-describe('Project Search Results', () => {
- it('should display project cards', () => {
- renderWithRouter(
-
-
- ,
- );
-
- expect(screen.getByText('Showing 2 of 11 projects')).toBeInTheDocument();
- expect(screen.getAllByRole('article').length).toBe(2);
- expect(screen.getByRole('heading', { name: 'NRCS_Duduwa Mapping' })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: 'NRCS_Khajura Mapping' })).toBeInTheDocument();
- });
-
- it('should not display card views when toggled to list view', () => {
- act(() => {
- store.dispatch({
- type: 'TOGGLE_LIST_VIEW',
- });
- });
-
- renderWithRouter(
-
-
- ,
- );
-
- expect(screen.queryAllByRole('article').length).toBe(0);
- expect(
- screen.getAllByRole('link', {
- name: /edit/i,
- }).length,
- ).toBe(2);
- });
-
- it('should display error and provide actionable to retry', async () => {
- const retryFn = jest.fn();
- const { user } = renderWithRouter(
-
-
- ,
- );
-
- expect(screen.getByText('Error loading the Projects for Explore Projects')).toBeInTheDocument();
- const retryBtn = screen.getByRole('button', { name: /retry/i });
- expect(retryBtn).toBeInTheDocument();
- await user.click(retryBtn);
- expect(retryFn).toHaveBeenCalled();
- });
-
- it('should display loading indicators', async () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(container.getElementsByClassName('show-loading-animation').length).toBeGreaterThan(0);
- });
-
- it('should display 0 projects if the pagination total is absent', async () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Showing 2 of 0 projects')).toBeInTheDocument();
- });
-});
-
-test('ExploreProjectCards should display empty DOM element when no project is passed as props', () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(container).toBeEmptyDOMElement();
-});
-
-test('ExploreProjectList should display empty DOM element when no project is passed as props', () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(container).toBeEmptyDOMElement();
-});
diff --git a/frontend/src/components/projects/tests/projectSearchResults.test.jsx b/frontend/src/components/projects/tests/projectSearchResults.test.jsx
new file mode 100644
index 0000000000..122ceb9ac5
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectSearchResults.test.jsx
@@ -0,0 +1,104 @@
+
+import { screen, act } from '@testing-library/react';
+
+import { ReduxIntlProviders, IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import {
+ ExploreProjectCards,
+ ExploreProjectList,
+ ProjectSearchResults,
+} from '../projectSearchResults';
+import { projects } from '../../../network/tests/mockData/projects';
+import { store } from '../../../store';
+
+describe('Project Search Results', () => {
+ it('should display project cards', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Showing 2 of 11 projects')).toBeInTheDocument();
+ expect(screen.getAllByRole('article').length).toBe(2);
+ expect(screen.getByRole('heading', { name: 'NRCS_Duduwa Mapping' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'NRCS_Khajura Mapping' })).toBeInTheDocument();
+ });
+
+ it('should not display card views when toggled to list view', () => {
+ act(() => {
+ store.dispatch({
+ type: 'TOGGLE_LIST_VIEW',
+ });
+ });
+
+ renderWithRouter(
+
+
+ ,
+ );
+
+ expect(screen.queryAllByRole('article').length).toBe(0);
+ expect(
+ screen.getAllByRole('link', {
+ name: /edit/i,
+ }).length,
+ ).toBe(2);
+ });
+
+ it('should display error and provide actionable to retry', async () => {
+ const retryFn = vi.fn();
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Error loading the Projects for Explore Projects')).toBeInTheDocument();
+ const retryBtn = screen.getByRole('button', { name: /retry/i });
+ expect(retryBtn).toBeInTheDocument();
+ await user.click(retryBtn);
+ expect(retryFn).toHaveBeenCalled();
+ });
+
+ it('should display loading indicators', async () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(container.getElementsByClassName('show-loading-animation').length).toBeGreaterThan(0);
+ });
+
+ it('should display 0 projects if the pagination total is absent', async () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Showing 2 of 0 projects')).toBeInTheDocument();
+ });
+});
+
+test('ExploreProjectCards should display empty DOM element when no project is passed as props', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+});
+
+test('ExploreProjectList should display empty DOM element when no project is passed as props', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+});
diff --git a/frontend/src/components/projects/tests/projectsActionFilter.test.js b/frontend/src/components/projects/tests/projectsActionFilter.test.js
deleted file mode 100644
index 383dd3d008..0000000000
--- a/frontend/src/components/projects/tests/projectsActionFilter.test.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import { screen, act } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { store } from '../../../store';
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { ProjectsActionFilter } from '../projectsActionFilter';
-
-describe('ProjectsActionFilter', () => {
- const myMock = jest.fn();
- it('test initialization and state changes', async () => {
- const { user } = renderWithRouter(
-
-
- ,
- );
- expect(screen.queryByText('Any project')).toBeInTheDocument();
- expect(screen.queryByText('Projects to map')).not.toBeInTheDocument();
- expect(screen.queryByText('Projects to validate')).not.toBeInTheDocument();
- expect(screen.queryByText('Archived')).not.toBeInTheDocument();
- // open dropdown
- await user.click(screen.queryByText('Any project'));
- expect(screen.queryByText('Projects to map')).toBeInTheDocument();
- expect(screen.queryByText('Projects to validate')).toBeInTheDocument();
- expect(screen.queryByText('Archived')).toBeInTheDocument();
- // select Projects to validate
- await user.click(screen.queryByText('Projects to validate'));
- expect(store.getState()['preferences']['action']).toBe('validate');
- // select Any projects
- await user.click(screen.queryByText('Projects to validate'));
- await user.click(screen.queryByText('Any project'));
- expect(store.getState()['preferences']['action']).toBe('any');
- // select Projects to map
- await user.click(screen.queryByText('Any project'));
- await user.click(screen.queryByText('Projects to map'));
- expect(store.getState()['preferences']['action']).toBe('map');
- // select Projects to archived, action set to any for this special case
- await user.click(screen.queryByText('Projects to map'));
- await user.click(screen.queryByText(/archived/i));
- expect(store.getState()['preferences']['action']).toBe('any');
- });
-
- it('initialize it with validate action set', async () => {
- const { user } = renderWithRouter(
-
-
- ,
- );
- expect(screen.queryByText('Projects to validate')).toBeInTheDocument();
- await user.click(screen.queryByText('Projects to validate'));
- await user.click(screen.queryByText('Any project'));
- expect(store.getState()['preferences']['action']).toBe('any');
- expect(myMock).toHaveBeenCalledTimes(2);
- });
-
- it('with an advanced user, the action is set as any', () => {
- act(() => {
- store.dispatch({
- type: 'SET_USER_DETAILS',
- userDetails: { username: 'abc', mappingLevel: 'ADVANCED' },
- });
- });
- renderWithRouter(
-
-
- ,
- );
- expect(screen.queryByText('Any project')).toBeInTheDocument();
- expect(store.getState()['preferences']['action']).toBe('any');
- });
-});
diff --git a/frontend/src/components/projects/tests/projectsActionFilter.test.jsx b/frontend/src/components/projects/tests/projectsActionFilter.test.jsx
new file mode 100644
index 0000000000..54ca1929b6
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectsActionFilter.test.jsx
@@ -0,0 +1,68 @@
+import { screen, act } from '@testing-library/react';
+import { store } from '../../../store';
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { ProjectsActionFilter } from '../projectsActionFilter';
+
+describe('ProjectsActionFilter', () => {
+ const myMock = vi.fn();
+ it('test initialization and state changes', async () => {
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.queryByText('Any project')).toBeInTheDocument();
+ expect(screen.queryByText('Projects to map')).not.toBeInTheDocument();
+ expect(screen.queryByText('Projects to validate')).not.toBeInTheDocument();
+ expect(screen.queryByText('Archived')).not.toBeInTheDocument();
+ // open dropdown
+ await user.click(screen.queryByText('Any project'));
+ expect(screen.queryByText('Projects to map')).toBeInTheDocument();
+ expect(screen.queryByText('Projects to validate')).toBeInTheDocument();
+ expect(screen.queryByText('Archived')).toBeInTheDocument();
+ // select Projects to validate
+ await user.click(screen.queryByText('Projects to validate'));
+ expect(store.getState()['preferences']['action']).toBe('validate');
+ // select Any projects
+ await user.click(screen.queryByText('Projects to validate'));
+ await user.click(screen.queryByText('Any project'));
+ expect(store.getState()['preferences']['action']).toBe('any');
+ // select Projects to map
+ await user.click(screen.queryByText('Any project'));
+ await user.click(screen.queryByText('Projects to map'));
+ expect(store.getState()['preferences']['action']).toBe('map');
+ // select Projects to archived, action set to any for this special case
+ await user.click(screen.queryByText('Projects to map'));
+ await user.click(screen.queryByText(/archived/i));
+ expect(store.getState()['preferences']['action']).toBe('any');
+ });
+
+ it('initialize it with validate action set', async () => {
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.queryByText('Projects to validate')).toBeInTheDocument();
+ await user.click(screen.queryByText('Projects to validate'));
+ await user.click(screen.queryByText('Any project'));
+ expect(store.getState()['preferences']['action']).toBe('any');
+ expect(myMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('with an advanced user, the action is set as any', () => {
+ act(() => {
+ store.dispatch({
+ type: 'SET_USER_DETAILS',
+ userDetails: { username: 'abc', mappingLevel: 'ADVANCED' },
+ });
+ });
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.queryByText('Any project')).toBeInTheDocument();
+ expect(store.getState()['preferences']['action']).toBe('any');
+ });
+});
diff --git a/frontend/src/components/projects/tests/projectsMap.test.js b/frontend/src/components/projects/tests/projectsMap.test.js
deleted file mode 100644
index 0123ad855e..0000000000
--- a/frontend/src/components/projects/tests/projectsMap.test.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import { ProjectsMap } from '../projectsMap';
-
-jest.mock('mapbox-gl/dist/mapbox-gl', () => ({
- supported: jest.fn(),
- Map: jest.fn(() => ({
- addControl: jest.fn(),
- on: jest.fn(),
- remove: jest.fn(),
- })),
-}));
-
-test('displays WebGL not supported message', () => {
- render(
-
-
- ,
- );
- expect(
- screen.getByRole('heading', {
- name: 'WebGL Context Not Found',
- }),
- ).toBeInTheDocument();
-});
diff --git a/frontend/src/components/projects/tests/projectsMap.test.jsx b/frontend/src/components/projects/tests/projectsMap.test.jsx
new file mode 100644
index 0000000000..606d72cab7
--- /dev/null
+++ b/frontend/src/components/projects/tests/projectsMap.test.jsx
@@ -0,0 +1,17 @@
+
+import { render, screen } from '@testing-library/react';
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import { ProjectsMap } from '../projectsMap';
+
+test('displays WebGL not supported message', () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('heading', {
+ name: 'WebGL Context Not Found',
+ }),
+ ).toBeInTheDocument();
+});
diff --git a/frontend/src/components/projects/tests/toggles.test.js b/frontend/src/components/projects/tests/toggles.test.js
deleted file mode 100644
index 92c97db770..0000000000
--- a/frontend/src/components/projects/tests/toggles.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { act } from 'react-test-renderer';
-
-import { store } from '../../../store';
-import { createComponentWithReduxAndIntl } from '../../../utils/testWithIntl';
-import { ShowMapToggle, ProjectListViewToggle } from '../projectNav';
-import { GripIcon, ListIcon } from '../../svgIcons';
-
-describe('test if ShowMapToggle component', () => {
- const element = createComponentWithReduxAndIntl( );
- const instance = element.root;
- it('has the correct CSS classes', () => {
- expect(instance.findByProps({ className: 'fr pv2 dib-ns dn blue-dark' }).type).toBe('div');
- });
- it('updates the redux state when clicked', () => {
- expect(store.getState().preferences['mapShown']).toBeFalsy();
- act(() => {
- instance.findByType('div').children[0].props.onChange();
- return undefined;
- });
- expect(store.getState().preferences['mapShown']).toBeTruthy();
- act(() => {
- instance.findByType('div').children[0].props.onChange();
- return undefined;
- });
- expect(store.getState().preferences['mapShown']).toBeFalsy();
- });
-});
-
-describe('test if ProjectListViewToggle', () => {
- const element = createComponentWithReduxAndIntl( );
- const instance = element.root;
- it('has the correct CSS classes', () => {
- expect(() => instance.findByType('div')).not.toThrow(
- new Error('No instances found with node type: "div"'),
- );
- expect(instance.findByType(GripIcon).props.className).toBe('dib pointer v-mid ph1 blue-grey');
- expect(instance.findByType(ListIcon).props.className).toBe('dib pointer v-mid ph1 blue-light');
- });
- it('updates the redux state and css classes when clicked', () => {
- expect(store.getState().preferences['projectListView']).toBeFalsy();
- act(() => {
- instance.findByType(ListIcon).props.onClick();
- return undefined;
- });
- expect(store.getState().preferences['projectListView']).toBeTruthy();
- expect(instance.findByType(GripIcon).props.className).toBe('dib pointer v-mid ph1 blue-light');
- expect(instance.findByType(ListIcon).props.className).toBe('dib pointer v-mid ph1 blue-grey');
- // click on GripIcon
- act(() => {
- instance.findByType(GripIcon).props.onClick();
- return undefined;
- });
- expect(store.getState().preferences['projectListView']).toBeFalsy();
- expect(instance.findByType(GripIcon).props.className).toBe('dib pointer v-mid ph1 blue-grey');
- expect(instance.findByType(ListIcon).props.className).toBe('dib pointer v-mid ph1 blue-light');
- });
-});
diff --git a/frontend/src/components/projects/tests/toggles.test.jsx b/frontend/src/components/projects/tests/toggles.test.jsx
new file mode 100644
index 0000000000..055509a103
--- /dev/null
+++ b/frontend/src/components/projects/tests/toggles.test.jsx
@@ -0,0 +1,56 @@
+import { render } from '@testing-library/react';
+import { store } from '../../../store';
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import { ShowMapToggle, ProjectListViewToggle } from '../projectNav';
+import userEvent from '@testing-library/user-event';
+
+describe('test if ShowMapToggle component', () => {
+ const setup = () =>
+ render(
+
+
+ ,
+ );
+ it('has the correct CSS classes', () => {
+ const { container } = setup();
+ expect(container.querySelector('.fr.pv2.dib-ns.dn.blue-dark')).toBeInTheDocument();
+ });
+ it('redux state is correct', () => {
+ setup();
+ expect(store.getState().preferences['mapShown']).toBeFalsy();
+ });
+});
+
+describe('test if ProjectListViewToggle', () => {
+ const setup = () =>
+ render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ it('has the correct CSS classes', () => {
+ const { container } = setup();
+ expect(container.getElementsByTagName('div').length).toBe(1);
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-light')).toBeInTheDocument();
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-grey')).toBeInTheDocument();
+ });
+ it('updates the redux state and css classes when clicked', async () => {
+ const { container } = setup();
+ expect(store.getState().preferences['projectListView']).toBeFalsy();
+ await user.click(container.querySelector('svg.dib.pointer.v-mid.ph1.blue-light'));
+ expect(store.getState().preferences['projectListView']).toBeTruthy();
+
+ // Check both icons have the correct classes
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-grey')).toBeInTheDocument();
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-light')).toBeInTheDocument();
+
+ // click on GripIcon
+ await user.click(container.querySelector('svg.dib.pointer.v-mid.ph1.blue-light'));
+ expect(store.getState().preferences['projectListView']).toBeFalsy();
+
+ // Check both icons have the correct classes
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-light')).toBeInTheDocument();
+ expect(container.querySelector('.dib.pointer.v-mid.ph1.blue-grey')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/rapidEditor.js b/frontend/src/components/rapidEditor.js
deleted file mode 100644
index 198299f254..0000000000
--- a/frontend/src/components/rapidEditor.js
+++ /dev/null
@@ -1,304 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-
-import PropTypes from 'prop-types';
-
-import {
- OSM_CLIENT_ID,
- OSM_CLIENT_SECRET,
- OSM_REDIRECT_URI,
- OSM_SERVER_API_URL,
- OSM_SERVER_URL,
-} from '../config';
-import { types } from '../store/actions/editor';
-
-// We import from a CDN using a SEMVER minor version range
-import rapidPackage from '@rapideditor/rapid/package.json';
-
-const baseCdnUrl = `https://cdn.jsdelivr.net/npm/${rapidPackage.name}@~${rapidPackage.version}/dist/`;
-// We currently copy rapid files to the public/static/rapid directory. This should probably remain,
-// since it can be useful for debugging rapid issues in the TM.
-// const baseCdnUrl = '/static/rapid/';
-
-/**
- * Check if two URL search parameters are semantically equal
- * @param {URLSearchParams} first
- * @param {URLSearchParams} second
- * @return {boolean} true if they are semantically equal
- */
-function equalsUrlParameters(first, second) {
- if (first.size === second.size) {
- for (const [key, value] of first) {
- if (!second.has(key) || second.get(key) !== value) {
- return false;
- }
- }
- return true;
- }
- return false;
-}
-
-/**
- * Update the URL (this also fires a hashchange event)
- * @param {URLSearchParams} hashParams the URL hash parameters
- */
-function updateUrl(hashParams) {
- const oldUrl = window.location.href;
- const newUrl = window.location.pathname + window.location.search + '#' + hashParams.toString();
- window.history.pushState(null, '', newUrl);
- window.dispatchEvent(
- new HashChangeEvent('hashchange', {
- newUrl: newUrl,
- oldUrl: oldUrl,
- }),
- );
-}
-
-/**
- * Generate the starting hash for the project
- * @param {string | undefined} comment The comment to use
- * @param {Array. | undefined} presets The presets
- * @param {string | undefined} gpxUrl The task boundaries
- * @param {boolean | undefined} powerUser if the user should be shown advanced options
- * @param {string | undefined} imagery The imagery to use for the task
- * @return {module:url.URLSearchParams | boolean} the new URL search params or {@code false} if no parameters changed
- */
-function generateStartingHash({ comment, presets, gpxUrl, powerUser, imagery }) {
- const hashParams = new URLSearchParams(window.location.hash.substring(1));
- if (comment) {
- hashParams.set('comment', comment);
- }
- if (gpxUrl) {
- hashParams.set('data', gpxUrl);
- }
- if (powerUser !== undefined) {
- hashParams.set('poweruser', powerUser.toString());
- }
- if (presets) {
- hashParams.set('presets', presets.join(','));
- }
- if (imagery) {
- if (imagery.startsWith('http')) {
- hashParams.set('background', 'custom:' + imagery);
- } else {
- hashParams.set('background', imagery);
- }
- }
- if (equalsUrlParameters(hashParams, new URLSearchParams(window.location.hash.substring(1)))) {
- return false;
- }
- return hashParams;
-}
-
-/**
- * Resize rapid
- * @param {Context} rapidContext The rapid context to resize
- * @type {import('@rapideditor/rapid').Context} Context
- */
-function resizeRapid(rapidContext) {
- // Get rid of black bars when toggling the TM sidebar
- const uiSystem = rapidContext?.systems?.ui;
- if (uiSystem?.started) {
- uiSystem.resize();
- }
-}
-
-/**
- * Check if there are changes
- * @param changes The changes to check
- * @returns {boolean} {@code true} if there are changes
- */
-function thereAreChanges(changes) {
- return changes.modified.length || changes.created.length || changes.deleted.length;
-}
-
-/**
- * Update the disable state for the sidebar map actions
- * @param {function(boolean)} setDisable
- * @param {EditSystem} editSystem The edit system
- * @type {import('@rapideditor/rapid/modules').EditSystem} EditSystem
- */
-function updateDisableState(setDisable, editSystem) {
- if (thereAreChanges(editSystem.changes())) {
- setDisable(true);
- } else {
- setDisable(false);
- }
-}
-
-/**
- * Create a new RapidEditor component
- * @param {function(boolean)} setDisable
- * @param {string} comment The default changeset comment
- * @param {[string]|null|undefined} presets The presets to allow the user to use
- * @param {string|null|undefined} imagery The imagery to default to for the user
- * @param {string} gpxUrl The task boundary url
- * @param {boolean} powerUser true if the user should be shown advanced options
- * @param {boolean} showSidebar Changes are used to resize the Rapid mapview
- * @returns {JSX.Element} The element to add to the DOM
- * @constructor
- */
-function RapidEditor({
- setDisable,
- comment,
- presets,
- imagery,
- gpxUrl,
- powerUser = false,
- showSidebar = true,
-}) {
- const dispatch = useDispatch();
- const session = useSelector((state) => state.auth.session);
- const [rapidLoaded, setRapidLoaded] = useState(window.Rapid !== undefined);
- const { context, dom } = useSelector((state) => state.editor.rapidContext);
- const locale = useSelector((state) => state.preferences.locale);
- const windowInit = typeof window !== 'undefined';
-
- // This significantly reduces build time _and_ means different TM instances can share the same download of Rapid.
- // Unfortunately, Rapid doesn't use a public CDN itself, so we cannot reuse that.
- useEffect(() => {
- if (!rapidLoaded && !context) {
- // Add the style element
- const style = document.createElement('link');
- style.setAttribute('type', 'text/css');
- style.setAttribute('rel', 'stylesheet');
- style.setAttribute('href', baseCdnUrl + 'rapid.css');
- document.head.appendChild(style);
- // Now add the editor
- const script = document.createElement('script');
- script.src = baseCdnUrl + 'rapid.js';
- script.async = true;
- script.onload = () => setRapidLoaded(true);
- document.body.appendChild(script);
- } else if (context && !rapidLoaded) {
- setRapidLoaded(true);
- }
- }, [rapidLoaded, setRapidLoaded, context]);
-
- useEffect(() => {
- return () => {
- dispatch({ type: 'SET_VISIBILITY', isVisible: true });
- };
- });
-
- useEffect(() => {
- if (windowInit && context === null && rapidLoaded) {
- /* This is used to avoid needing to re-initialize Rapid on every page load -- this can lead to jerky movements in the UI */
- const dom = document.createElement('div');
- dom.className = 'w-100 vh-minus-69-ns';
- // we need to keep Rapid context on redux store because Rapid works better if
- // the context is not restarted while running in the same browser session
- // Unfortunately, we need to recreate the context every time we recreate the rapid-container dom node.
- const context = new window.Rapid.Context();
- context.embed(true);
- context.containerNode = dom;
- context.assetPath = baseCdnUrl;
- context.apiConnections = [
- {
- url: OSM_SERVER_URL,
- apiUrl: OSM_SERVER_API_URL,
- client_id: OSM_CLIENT_ID,
- client_secret: OSM_CLIENT_SECRET,
- redirect_uri: OSM_REDIRECT_URI,
- },
- ];
- dispatch({ type: types.SET_RAPIDEDITOR, context: { context, dom } });
- }
- }, [windowInit, rapidLoaded, context, dispatch]);
-
- useEffect(() => {
- if (context) {
- // setup the context
- context.locale = locale;
- }
- }, [context, locale]);
-
- // This ensures that Rapid has the correct map size
- useEffect(() => {
- // This might be a _slight_ efficiency improvement by making certain that Rapid isn't painting unneeded items
- resizeRapid(context);
- // This is the only bit that is *really* needed -- it prevents black bars when hiding the sidebar.
- return () => resizeRapid(context);
- }, [showSidebar, context]);
-
- useEffect(() => {
- const newParams = generateStartingHash({ comment, presets, gpxUrl, powerUser, imagery });
- if (newParams) {
- updateUrl(newParams);
- }
- }, [comment, presets, gpxUrl, powerUser, imagery]);
-
- useEffect(() => {
- const containerRoot = document.getElementById('rapid-container-root');
- const editListener = () => updateDisableState(setDisable, context.systems.editor);
- if (context && dom) {
- containerRoot.appendChild(dom);
- // init the ui or restart if it was loaded previously
- let promise;
- if (context?.systems?.ui !== undefined) {
- // Currently commented out in Rapid source code (2023-07-20)
- // RapidContext.systems.ui.restart();
- resizeRapid(context);
- promise = Promise.resolve();
- } else {
- promise = context.initAsync();
- }
-
- /* Perform tasks after Rapid has started up */
- promise.then(() => {
- if (context?.systems?.editor) {
- /* Keep track of edits */
- const editSystem = context.systems.editor;
-
- editSystem.on('stablechange', editListener);
- editSystem.on('reset', editListener);
- }
- });
- }
- return () => {
- if (containerRoot?.childNodes && dom in containerRoot.childNodes) {
- document.getElementById('rapid-container-root')?.removeChild(dom);
- }
- if (context?.systems?.editor) {
- const editSystem = context.systems.editor;
- editSystem.off('stablechange', editListener);
- editSystem.off('reset', editListener);
- }
- };
- }, [dom, context, setDisable]);
-
- useEffect(() => {
- if (context?.systems?.editor) {
- return () => context.systems.editor.saveBackup();
- }
- }, [context]);
-
- useEffect(() => {
- if (context && session) {
- context.preauth = {
- url: OSM_SERVER_URL,
- apiUrl: OSM_SERVER_API_URL,
- client_id: OSM_CLIENT_ID,
- client_secret: OSM_CLIENT_SECRET,
- redirect_uri: OSM_REDIRECT_URI,
- access_token: session.osm_oauth_token,
- };
- context.apiConnections = [context.preauth];
- }
- }, [context, session, session?.osm_oauth_token]);
-
- return
;
-}
-
-RapidEditor.propTypes = {
- setDisable: PropTypes.func,
- comment: PropTypes.string,
- presets: PropTypes.array,
- imagery: PropTypes.string,
- gpxUrl: PropTypes.string.isRequired,
- powerUser: PropTypes.bool.isRequired,
- showSidebar: PropTypes.bool.isRequired,
-};
-
-export { RapidEditor, generateStartingHash, equalsUrlParameters, updateUrl };
-export default RapidEditor;
diff --git a/frontend/src/components/rapidEditor.jsx b/frontend/src/components/rapidEditor.jsx
new file mode 100644
index 0000000000..ae30c14130
--- /dev/null
+++ b/frontend/src/components/rapidEditor.jsx
@@ -0,0 +1,304 @@
+import { useEffect, useState } from 'react';
+import { useTypedDispatch, useTypedSelector } from '@Store/hooks';
+
+import PropTypes from 'prop-types';
+
+import {
+ OSM_CLIENT_ID,
+ OSM_CLIENT_SECRET,
+ OSM_REDIRECT_URI,
+ OSM_SERVER_API_URL,
+ OSM_SERVER_URL,
+} from '../config';
+import { types } from '../store/actions/editor';
+
+// We import from a CDN using a SEMVER minor version range
+import rapidPackage from '@rapideditor/rapid/package.json';
+
+const baseCdnUrl = `https://cdn.jsdelivr.net/npm/${rapidPackage.name}@~${rapidPackage.version}/dist/`;
+// We currently copy rapid files to the public/static/rapid directory. This should probably remain,
+// since it can be useful for debugging rapid issues in the TM.
+// const baseCdnUrl = '/static/rapid/';
+
+/**
+ * Check if two URL search parameters are semantically equal
+ * @param {URLSearchParams} first
+ * @param {URLSearchParams} second
+ * @return {boolean} true if they are semantically equal
+ */
+function equalsUrlParameters(first, second) {
+ if (first.size === second.size) {
+ for (const [key, value] of first) {
+ if (!second.has(key) || second.get(key) !== value) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Update the URL (this also fires a hashchange event)
+ * @param {URLSearchParams} hashParams the URL hash parameters
+ */
+function updateUrl(hashParams) {
+ const oldUrl = window.location.href;
+ const newUrl = window.location.pathname + window.location.search + '#' + hashParams.toString();
+ window.history.pushState(null, '', newUrl);
+ window.dispatchEvent(
+ new HashChangeEvent('hashchange', {
+ newUrl: newUrl,
+ oldUrl: oldUrl,
+ }),
+ );
+}
+
+/**
+ * Generate the starting hash for the project
+ * @param {string | undefined} comment The comment to use
+ * @param {Array. | undefined} presets The presets
+ * @param {string | undefined} gpxUrl The task boundaries
+ * @param {boolean | undefined} powerUser if the user should be shown advanced options
+ * @param {string | undefined} imagery The imagery to use for the task
+ * @return {module:url.URLSearchParams | boolean} the new URL search params or {@code false} if no parameters changed
+ */
+function generateStartingHash({ comment, presets, gpxUrl, powerUser, imagery }) {
+ const hashParams = new URLSearchParams(window.location.hash.substring(1));
+ if (comment) {
+ hashParams.set('comment', comment);
+ }
+ if (gpxUrl) {
+ hashParams.set('data', gpxUrl);
+ }
+ if (powerUser !== undefined) {
+ hashParams.set('poweruser', powerUser.toString());
+ }
+ if (presets) {
+ hashParams.set('presets', presets.join(','));
+ }
+ if (imagery) {
+ if (imagery.startsWith('http')) {
+ hashParams.set('background', 'custom:' + imagery);
+ } else {
+ hashParams.set('background', imagery);
+ }
+ }
+ if (equalsUrlParameters(hashParams, new URLSearchParams(window.location.hash.substring(1)))) {
+ return false;
+ }
+ return hashParams;
+}
+
+/**
+ * Resize rapid
+ * @param {Context} rapidContext The rapid context to resize
+ * @type {import('@rapideditor/rapid').Context} Context
+ */
+function resizeRapid(rapidContext) {
+ // Get rid of black bars when toggling the TM sidebar
+ const uiSystem = rapidContext?.systems?.ui;
+ if (uiSystem?.started) {
+ uiSystem.resize();
+ }
+}
+
+/**
+ * Check if there are changes
+ * @param changes The changes to check
+ * @returns {boolean} {@code true} if there are changes
+ */
+function thereAreChanges(changes) {
+ return changes.modified.length || changes.created.length || changes.deleted.length;
+}
+
+/**
+ * Update the disable state for the sidebar map actions
+ * @param {function(boolean)} setDisable
+ * @param {EditSystem} editSystem The edit system
+ * @type {import('@rapideditor/rapid/modules').EditSystem} EditSystem
+ */
+function updateDisableState(setDisable, editSystem) {
+ if (thereAreChanges(editSystem.changes())) {
+ setDisable(true);
+ } else {
+ setDisable(false);
+ }
+}
+
+/**
+ * Create a new RapidEditor component
+ * @param {function(boolean)} setDisable
+ * @param {string} comment The default changeset comment
+ * @param {[string]|null|undefined} presets The presets to allow the user to use
+ * @param {string|null|undefined} imagery The imagery to default to for the user
+ * @param {string} gpxUrl The task boundary url
+ * @param {boolean} powerUser true if the user should be shown advanced options
+ * @param {boolean} showSidebar Changes are used to resize the Rapid mapview
+ * @returns {JSX.Element} The element to add to the DOM
+ * @constructor
+ */
+function RapidEditor({
+ setDisable,
+ comment,
+ presets,
+ imagery,
+ gpxUrl,
+ powerUser = false,
+ showSidebar = true,
+}) {
+ const dispatch = useTypedDispatch();
+ const session = useTypedSelector((state) => state.auth.session);
+ const [rapidLoaded, setRapidLoaded] = useState(window.Rapid !== undefined);
+ const { context, dom } = useTypedSelector((state) => state.editor.rapidContext);
+ const locale = useTypedSelector((state) => state.preferences.locale);
+ const windowInit = typeof window !== 'undefined';
+
+ // This significantly reduces build time _and_ means different TM instances can share the same download of Rapid.
+ // Unfortunately, Rapid doesn't use a public CDN itself, so we cannot reuse that.
+ useEffect(() => {
+ if (!rapidLoaded && !context) {
+ // Add the style element
+ const style = document.createElement('link');
+ style.setAttribute('type', 'text/css');
+ style.setAttribute('rel', 'stylesheet');
+ style.setAttribute('href', baseCdnUrl + 'rapid.css');
+ document.head.appendChild(style);
+ // Now add the editor
+ const script = document.createElement('script');
+ script.src = baseCdnUrl + 'rapid.js';
+ script.async = true;
+ script.onload = () => setRapidLoaded(true);
+ document.body.appendChild(script);
+ } else if (context && !rapidLoaded) {
+ setRapidLoaded(true);
+ }
+ }, [rapidLoaded, setRapidLoaded, context]);
+
+ useEffect(() => {
+ return () => {
+ dispatch({ type: 'SET_VISIBILITY', isVisible: true });
+ };
+ });
+
+ useEffect(() => {
+ if (windowInit && context === null && rapidLoaded) {
+ /* This is used to avoid needing to re-initialize Rapid on every page load -- this can lead to jerky movements in the UI */
+ const dom = document.createElement('div');
+ dom.className = 'w-100 vh-minus-69-ns';
+ // we need to keep Rapid context on redux store because Rapid works better if
+ // the context is not restarted while running in the same browser session
+ // Unfortunately, we need to recreate the context every time we recreate the rapid-container dom node.
+ const context = new window.Rapid.Context();
+ context.embed(true);
+ context.containerNode = dom;
+ context.assetPath = baseCdnUrl;
+ context.apiConnections = [
+ {
+ url: OSM_SERVER_URL,
+ apiUrl: OSM_SERVER_API_URL,
+ client_id: OSM_CLIENT_ID,
+ client_secret: OSM_CLIENT_SECRET,
+ redirect_uri: OSM_REDIRECT_URI,
+ },
+ ];
+ dispatch({ type: types.SET_RAPIDEDITOR, context: { context, dom } });
+ }
+ }, [windowInit, rapidLoaded, context, dispatch]);
+
+ useEffect(() => {
+ if (context) {
+ // setup the context
+ context.locale = locale;
+ }
+ }, [context, locale]);
+
+ // This ensures that Rapid has the correct map size
+ useEffect(() => {
+ // This might be a _slight_ efficiency improvement by making certain that Rapid isn't painting unneeded items
+ resizeRapid(context);
+ // This is the only bit that is *really* needed -- it prevents black bars when hiding the sidebar.
+ return () => resizeRapid(context);
+ }, [showSidebar, context]);
+
+ useEffect(() => {
+ const newParams = generateStartingHash({ comment, presets, gpxUrl, powerUser, imagery });
+ if (newParams) {
+ updateUrl(newParams);
+ }
+ }, [comment, presets, gpxUrl, powerUser, imagery]);
+
+ useEffect(() => {
+ const containerRoot = document.getElementById('rapid-container-root');
+ const editListener = () => updateDisableState(setDisable, context.systems.editor);
+ if (context && dom) {
+ containerRoot.appendChild(dom);
+ // init the ui or restart if it was loaded previously
+ let promise;
+ if (context?.systems?.ui !== undefined) {
+ // Currently commented out in Rapid source code (2023-07-20)
+ // RapidContext.systems.ui.restart();
+ resizeRapid(context);
+ promise = Promise.resolve();
+ } else {
+ promise = context.initAsync();
+ }
+
+ /* Perform tasks after Rapid has started up */
+ promise.then(() => {
+ if (context?.systems?.editor) {
+ /* Keep track of edits */
+ const editSystem = context.systems.editor;
+
+ editSystem.on('stablechange', editListener);
+ editSystem.on('reset', editListener);
+ }
+ });
+ }
+ return () => {
+ if (containerRoot?.childNodes && dom in containerRoot.childNodes) {
+ document.getElementById('rapid-container-root')?.removeChild(dom);
+ }
+ if (context?.systems?.editor) {
+ const editSystem = context.systems.editor;
+ editSystem.off('stablechange', editListener);
+ editSystem.off('reset', editListener);
+ }
+ };
+ }, [dom, context, setDisable]);
+
+ useEffect(() => {
+ if (context?.systems?.editor) {
+ return () => context.systems.editor.saveBackup();
+ }
+ }, [context]);
+
+ useEffect(() => {
+ if (context && session) {
+ context.preauth = {
+ url: OSM_SERVER_URL,
+ apiUrl: OSM_SERVER_API_URL,
+ client_id: OSM_CLIENT_ID,
+ client_secret: OSM_CLIENT_SECRET,
+ redirect_uri: OSM_REDIRECT_URI,
+ access_token: session.osm_oauth_token,
+ };
+ context.apiConnections = [context.preauth];
+ }
+ }, [context, session, session?.osm_oauth_token]);
+
+ return
;
+}
+
+RapidEditor.propTypes = {
+ setDisable: PropTypes.func,
+ comment: PropTypes.string,
+ presets: PropTypes.array,
+ imagery: PropTypes.string,
+ gpxUrl: PropTypes.string.isRequired,
+ powerUser: PropTypes.bool.isRequired,
+ showSidebar: PropTypes.bool.isRequired,
+};
+
+export { RapidEditor, generateStartingHash, equalsUrlParameters, updateUrl };
+export default RapidEditor;
diff --git a/frontend/src/components/redirect.js b/frontend/src/components/redirect.jsx
similarity index 100%
rename from frontend/src/components/redirect.js
rename to frontend/src/components/redirect.jsx
diff --git a/frontend/src/components/statsCard.js b/frontend/src/components/statsCard.js
deleted file mode 100644
index cba58debd9..0000000000
--- a/frontend/src/components/statsCard.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import { FormattedNumber } from 'react-intl';
-import PropTypes from 'prop-types';
-
-import shortNumber from 'short-number';
-
-export const StatsCard = ({ icon, description, value, className, invertColors = false }) => {
- return (
-
-
{icon}
-
: value}
- label={description}
- className="w-70 pt3-m mb1 fl"
- invertColors={invertColors}
- />
-
- );
-};
-
-export const StatsCardWithFooter = ({
- icon,
- description,
- value,
- className,
- delta,
- invertColors = false,
- style,
-}) => {
- return (
-
-
-
{icon}
-
: value
- }
- label={description}
- className="w-70 pt3-m mb1 fl"
- invertColors={invertColors}
- />
-
- {delta ? (
-
- {delta}
-
- ) : null}
-
- );
-};
-
-StatsCardWithFooter.propTypes = {
- icon: PropTypes.node,
- description: PropTypes.node,
- value: PropTypes.node,
- className: PropTypes.string,
- delta: PropTypes.node,
- invertColors: PropTypes.bool,
- style: PropTypes.object,
-};
-
-export const StatsCardContent = ({ value, label, className, invertColors = false }: Object) => (
-
-
{value}
- {label}
-
-);
-
-export const StatsCardWithFooterContent = ({ value, label, className, invertColors = false }) => (
-
-
{value}
- {label}
-
-);
-
-StatsCardWithFooterContent.propTypes = {
- value: PropTypes.node,
- label: PropTypes.node,
- className: PropTypes.string,
- invertColors: PropTypes.bool,
-};
-
-function getFormattedNumber(num) {
- if (typeof num !== 'number') return '-';
- const value = shortNumber(num);
- return typeof value === 'number' ? : value;
-}
-
-export const DetailedStatsCard = ({
- icon,
- description,
- subDescription,
- mapped,
- created,
- modified,
- deleted,
- unitMore,
- unitLess,
-}) => {
- return (
-
-
-
{icon}
-
-
{getFormattedNumber(mapped)}
-
- {description}
- {subDescription}
-
-
-
-
- {/* seperator line */}
-
-
-
-
-
{getFormattedNumber(created)}
- Created
-
-
-
- {!isNaN(unitMore || unitLess) ? (
- <>
- +{getFormattedNumber(unitMore)}
- {/* seperator line */}
-
- -{getFormattedNumber(unitLess)}
- >
- ) : (
- getFormattedNumber(modified)
- )}
-
-
Modified
-
-
-
{getFormattedNumber(deleted)}
- Deleted
-
-
-
- );
-};
diff --git a/frontend/src/components/statsCard.jsx b/frontend/src/components/statsCard.jsx
new file mode 100644
index 0000000000..582617c3ae
--- /dev/null
+++ b/frontend/src/components/statsCard.jsx
@@ -0,0 +1,161 @@
+import { FormattedNumber } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import shortNumber from 'short-number';
+
+export const StatsCard = ({ icon, description, value, className, invertColors = false }) => {
+ return (
+
+
{icon}
+
: value}
+ label={description}
+ className="w-70 pt3-m mb1 fl"
+ invertColors={invertColors}
+ />
+
+ );
+};
+
+export const StatsCardWithFooter = ({
+ icon,
+ description,
+ value,
+ className,
+ delta,
+ invertColors = false,
+ style,
+}) => {
+ return (
+
+
+
{icon}
+
: value
+ }
+ label={description}
+ className="w-70 pt3-m mb1 fl"
+ invertColors={invertColors}
+ />
+
+ {delta ? (
+
+ {delta}
+
+ ) : null}
+
+ );
+};
+
+StatsCardWithFooter.propTypes = {
+ icon: PropTypes.node,
+ description: PropTypes.node,
+ value: PropTypes.node,
+ className: PropTypes.string,
+ delta: PropTypes.node,
+ invertColors: PropTypes.bool,
+ style: PropTypes.object,
+};
+
+export const StatsCardContent = ({ value, label, className, invertColors = false }) => (
+
+
{value}
+ {label}
+
+);
+
+export const StatsCardWithFooterContent = ({ value, label, className, invertColors = false }) => (
+
+
{value}
+ {label}
+
+);
+
+StatsCardWithFooterContent.propTypes = {
+ value: PropTypes.node,
+ label: PropTypes.node,
+ className: PropTypes.string,
+ invertColors: PropTypes.bool,
+};
+
+function getFormattedNumber(num) {
+ if (typeof num !== 'number') return '-';
+ const value = shortNumber(num);
+ return typeof value === 'number' ? : value;
+}
+
+export const DetailedStatsCard = ({
+ icon,
+ description,
+ subDescription,
+ mapped,
+ created,
+ modified,
+ deleted,
+ unitMore,
+ unitLess,
+}) => {
+ return (
+
+
+
{icon}
+
+
{getFormattedNumber(mapped)}
+
+ {description}
+ {subDescription}
+
+
+
+
+ {/* seperator line */}
+
+
+
+
+
{getFormattedNumber(created)}
+ Created
+
+
+
+ {!isNaN(unitMore || unitLess) ? (
+ <>
+ +{getFormattedNumber(unitMore)}
+ {/* seperator line */}
+
+ -{getFormattedNumber(unitLess)}
+ >
+ ) : (
+ getFormattedNumber(modified)
+ )}
+
+
Modified
+
+
+
{getFormattedNumber(deleted)}
+ Deleted
+
+
+
+ );
+};
diff --git a/frontend/src/components/statsTimestamp/index.js b/frontend/src/components/statsTimestamp/index.jsx
similarity index 100%
rename from frontend/src/components/statsTimestamp/index.js
rename to frontend/src/components/statsTimestamp/index.jsx
diff --git a/frontend/src/components/statsTimestamp/messages.js b/frontend/src/components/statsTimestamp/messages.ts
similarity index 100%
rename from frontend/src/components/statsTimestamp/messages.js
rename to frontend/src/components/statsTimestamp/messages.ts
diff --git a/frontend/src/components/svgIcons/alert.js b/frontend/src/components/svgIcons/alert.js
deleted file mode 100644
index 4085c6e251..0000000000
--- a/frontend/src/components/svgIcons/alert.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class AlertIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/alert.tsx b/frontend/src/components/svgIcons/alert.tsx
new file mode 100644
index 0000000000..b2523853d4
--- /dev/null
+++ b/frontend/src/components/svgIcons/alert.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+export const AlertIcon = (props: HTMLProps) => {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/svgIcons/area.js b/frontend/src/components/svgIcons/area.js
deleted file mode 100644
index df16345bd9..0000000000
--- a/frontend/src/components/svgIcons/area.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { PureComponent } from 'react';
-
-export class AreaIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/area.tsx b/frontend/src/components/svgIcons/area.tsx
new file mode 100644
index 0000000000..f74378e390
--- /dev/null
+++ b/frontend/src/components/svgIcons/area.tsx
@@ -0,0 +1,28 @@
+import { HTMLProps } from 'react';
+
+export const AreaIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/asterisk.js b/frontend/src/components/svgIcons/asterisk.js
deleted file mode 100644
index 5f8de49bc1..0000000000
--- a/frontend/src/components/svgIcons/asterisk.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class AsteriskIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/asterisk.tsx b/frontend/src/components/svgIcons/asterisk.tsx
new file mode 100644
index 0000000000..3934ff1f95
--- /dev/null
+++ b/frontend/src/components/svgIcons/asterisk.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const AsteriskIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/ban.js b/frontend/src/components/svgIcons/ban.js
deleted file mode 100644
index 0daa94515a..0000000000
--- a/frontend/src/components/svgIcons/ban.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class BanIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/ban.tsx b/frontend/src/components/svgIcons/ban.tsx
new file mode 100644
index 0000000000..aa0bee3237
--- /dev/null
+++ b/frontend/src/components/svgIcons/ban.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const BanIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/bell.js b/frontend/src/components/svgIcons/bell.js
deleted file mode 100644
index c09df19c18..0000000000
--- a/frontend/src/components/svgIcons/bell.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-export class BellIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/bell.tsx b/frontend/src/components/svgIcons/bell.tsx
new file mode 100644
index 0000000000..72708b41cb
--- /dev/null
+++ b/frontend/src/components/svgIcons/bell.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+export const BellIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/calendar.js b/frontend/src/components/svgIcons/calendar.js
deleted file mode 100644
index a66725b5a7..0000000000
--- a/frontend/src/components/svgIcons/calendar.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class CalendarIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/calendar.tsx b/frontend/src/components/svgIcons/calendar.tsx
new file mode 100644
index 0000000000..1728509c31
--- /dev/null
+++ b/frontend/src/components/svgIcons/calendar.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const CalendarIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/chart.js b/frontend/src/components/svgIcons/chart.js
deleted file mode 100644
index 05fae045e3..0000000000
--- a/frontend/src/components/svgIcons/chart.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class ChartLineIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/chart.tsx b/frontend/src/components/svgIcons/chart.tsx
new file mode 100644
index 0000000000..7a9b227e18
--- /dev/null
+++ b/frontend/src/components/svgIcons/chart.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const ChartLineIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/check.js b/frontend/src/components/svgIcons/check.js
deleted file mode 100644
index e9fcdd1a43..0000000000
--- a/frontend/src/components/svgIcons/check.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class CheckIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/check.tsx b/frontend/src/components/svgIcons/check.tsx
new file mode 100644
index 0000000000..6e18674c08
--- /dev/null
+++ b/frontend/src/components/svgIcons/check.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from 'react';
+
+export const CheckIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/checksGrid.js b/frontend/src/components/svgIcons/checksGrid.js
deleted file mode 100644
index 13765e4e64..0000000000
--- a/frontend/src/components/svgIcons/checksGrid.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ChecksGridIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/checksGrid.tsx b/frontend/src/components/svgIcons/checksGrid.tsx
new file mode 100644
index 0000000000..0917d3f474
--- /dev/null
+++ b/frontend/src/components/svgIcons/checksGrid.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const ChecksGridIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/chevron-down.js b/frontend/src/components/svgIcons/chevron-down.js
deleted file mode 100644
index 21bd74b378..0000000000
--- a/frontend/src/components/svgIcons/chevron-down.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ChevronDownIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/chevron-down.tsx b/frontend/src/components/svgIcons/chevron-down.tsx
new file mode 100644
index 0000000000..00e8c5cb54
--- /dev/null
+++ b/frontend/src/components/svgIcons/chevron-down.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const ChevronDownIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/chevron-right.js b/frontend/src/components/svgIcons/chevron-right.js
deleted file mode 100644
index c75175cb04..0000000000
--- a/frontend/src/components/svgIcons/chevron-right.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ChevronRightIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/chevron-right.tsx b/frontend/src/components/svgIcons/chevron-right.tsx
new file mode 100644
index 0000000000..f9de898066
--- /dev/null
+++ b/frontend/src/components/svgIcons/chevron-right.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const ChevronRightIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/chevron-up.js b/frontend/src/components/svgIcons/chevron-up.js
deleted file mode 100644
index 850120d1c1..0000000000
--- a/frontend/src/components/svgIcons/chevron-up.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ChevronUpIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/chevron-up.tsx b/frontend/src/components/svgIcons/chevron-up.tsx
new file mode 100644
index 0000000000..eadf5aeecf
--- /dev/null
+++ b/frontend/src/components/svgIcons/chevron-up.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const ChevronUpIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/circle.js b/frontend/src/components/svgIcons/circle.js
deleted file mode 100644
index c778c06b3c..0000000000
--- a/frontend/src/components/svgIcons/circle.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class CircleIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/circle.tsx b/frontend/src/components/svgIcons/circle.tsx
new file mode 100644
index 0000000000..09132044d9
--- /dev/null
+++ b/frontend/src/components/svgIcons/circle.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const CircleIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/circleExclamation.js b/frontend/src/components/svgIcons/circleExclamation.js
deleted file mode 100644
index 4163d4fa03..0000000000
--- a/frontend/src/components/svgIcons/circleExclamation.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { PureComponent } from 'react';
-
-export class CircleExclamationIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/circleExclamation.tsx b/frontend/src/components/svgIcons/circleExclamation.tsx
new file mode 100644
index 0000000000..9e11301a55
--- /dev/null
+++ b/frontend/src/components/svgIcons/circleExclamation.tsx
@@ -0,0 +1,17 @@
+import { HTMLProps } from 'react';
+
+export const CircleExclamationIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/circleMinus.js b/frontend/src/components/svgIcons/circleMinus.js
deleted file mode 100644
index 51c67bb61a..0000000000
--- a/frontend/src/components/svgIcons/circleMinus.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { PureComponent } from 'react';
-
-export class CircleMinusIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/circleMinus.tsx b/frontend/src/components/svgIcons/circleMinus.tsx
new file mode 100644
index 0000000000..e752f28816
--- /dev/null
+++ b/frontend/src/components/svgIcons/circleMinus.tsx
@@ -0,0 +1,17 @@
+import { HTMLProps } from 'react';
+
+export const CircleMinusIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/clipboard.js b/frontend/src/components/svgIcons/clipboard.js
deleted file mode 100644
index 8c1e94d192..0000000000
--- a/frontend/src/components/svgIcons/clipboard.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class ClipboardIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/clipboard.tsx b/frontend/src/components/svgIcons/clipboard.tsx
new file mode 100644
index 0000000000..b338f48aa3
--- /dev/null
+++ b/frontend/src/components/svgIcons/clipboard.tsx
@@ -0,0 +1,14 @@
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+
+import { HTMLProps } from "react";
+
+// License: CC-By 4.0
+export const ClipboardIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/clock.js b/frontend/src/components/svgIcons/clock.js
deleted file mode 100644
index 9d0459913e..0000000000
--- a/frontend/src/components/svgIcons/clock.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ClockIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/clock.tsx b/frontend/src/components/svgIcons/clock.tsx
new file mode 100644
index 0000000000..23ea89f1fe
--- /dev/null
+++ b/frontend/src/components/svgIcons/clock.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const ClockIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/close.js b/frontend/src/components/svgIcons/close.js
deleted file mode 100644
index d7122d4879..0000000000
--- a/frontend/src/components/svgIcons/close.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class CloseIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/close.tsx b/frontend/src/components/svgIcons/close.tsx
new file mode 100644
index 0000000000..4732976c7e
--- /dev/null
+++ b/frontend/src/components/svgIcons/close.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const CloseIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/columnsGap.js b/frontend/src/components/svgIcons/columnsGap.js
deleted file mode 100644
index c6c50c6b1d..0000000000
--- a/frontend/src/components/svgIcons/columnsGap.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ColumnsGapIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/columnsGap.tsx b/frontend/src/components/svgIcons/columnsGap.tsx
new file mode 100644
index 0000000000..140367813a
--- /dev/null
+++ b/frontend/src/components/svgIcons/columnsGap.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const ColumnsGapIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/comment.js b/frontend/src/components/svgIcons/comment.js
deleted file mode 100644
index 38ce263370..0000000000
--- a/frontend/src/components/svgIcons/comment.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class CommentIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/comment.tsx b/frontend/src/components/svgIcons/comment.tsx
new file mode 100644
index 0000000000..35ccf9edf1
--- /dev/null
+++ b/frontend/src/components/svgIcons/comment.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const CommentIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/copyright.js b/frontend/src/components/svgIcons/copyright.js
deleted file mode 100644
index 2ac5918e1f..0000000000
--- a/frontend/src/components/svgIcons/copyright.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class CopyrightIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/copyright.tsx b/frontend/src/components/svgIcons/copyright.tsx
new file mode 100644
index 0000000000..8bcd0a12f9
--- /dev/null
+++ b/frontend/src/components/svgIcons/copyright.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const CopyrightIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/cut.js b/frontend/src/components/svgIcons/cut.js
deleted file mode 100644
index 76b169340a..0000000000
--- a/frontend/src/components/svgIcons/cut.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class CutIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/cut.tsx b/frontend/src/components/svgIcons/cut.tsx
new file mode 100644
index 0000000000..5acb011131
--- /dev/null
+++ b/frontend/src/components/svgIcons/cut.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const CutIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/dataUse.js b/frontend/src/components/svgIcons/dataUse.js
deleted file mode 100644
index 791ec18e4e..0000000000
--- a/frontend/src/components/svgIcons/dataUse.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { PureComponent } from 'react';
-
-export class DataUseIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/dataUse.tsx b/frontend/src/components/svgIcons/dataUse.tsx
new file mode 100644
index 0000000000..0ce4cff3a9
--- /dev/null
+++ b/frontend/src/components/svgIcons/dataUse.tsx
@@ -0,0 +1,20 @@
+import { HTMLProps } from 'react';
+
+export const DataUseIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/disasterResponse.js b/frontend/src/components/svgIcons/disasterResponse.js
deleted file mode 100644
index 7b0d141c03..0000000000
--- a/frontend/src/components/svgIcons/disasterResponse.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { PureComponent } from 'react';
-
-export class DisasterResponseIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/disasterResponse.tsx b/frontend/src/components/svgIcons/disasterResponse.tsx
new file mode 100644
index 0000000000..cdfc71bb30
--- /dev/null
+++ b/frontend/src/components/svgIcons/disasterResponse.tsx
@@ -0,0 +1,29 @@
+import { HTMLProps } from 'react';
+
+export const DisasterResponseIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/download.js b/frontend/src/components/svgIcons/download.js
deleted file mode 100644
index 04bdabba33..0000000000
--- a/frontend/src/components/svgIcons/download.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-
-export class DownloadIcon extends React.PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/download.tsx b/frontend/src/components/svgIcons/download.tsx
new file mode 100644
index 0000000000..6f9103b362
--- /dev/null
+++ b/frontend/src/components/svgIcons/download.tsx
@@ -0,0 +1,18 @@
+import { HTMLProps } from 'react';
+
+export const DownloadIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/edit.js b/frontend/src/components/svgIcons/edit.js
deleted file mode 100644
index 1c8b41e1c9..0000000000
--- a/frontend/src/components/svgIcons/edit.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class EditIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/edit.tsx b/frontend/src/components/svgIcons/edit.tsx
new file mode 100644
index 0000000000..546ee87ca6
--- /dev/null
+++ b/frontend/src/components/svgIcons/edit.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const EditIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/emptySet.js b/frontend/src/components/svgIcons/emptySet.js
deleted file mode 100644
index 4ff8f5d2e8..0000000000
--- a/frontend/src/components/svgIcons/emptySet.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { PureComponent } from 'react';
-
-export class EmptySetIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/emptySet.tsx b/frontend/src/components/svgIcons/emptySet.tsx
new file mode 100644
index 0000000000..d94dad271f
--- /dev/null
+++ b/frontend/src/components/svgIcons/emptySet.tsx
@@ -0,0 +1,21 @@
+import { HTMLProps } from 'react';
+
+export const EmptySetIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/envelope.js b/frontend/src/components/svgIcons/envelope.js
deleted file mode 100644
index 0772d4b0ae..0000000000
--- a/frontend/src/components/svgIcons/envelope.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class EnvelopeIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/envelope.tsx b/frontend/src/components/svgIcons/envelope.tsx
new file mode 100644
index 0000000000..a85446cbc9
--- /dev/null
+++ b/frontend/src/components/svgIcons/envelope.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const EnvelopeIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/exit.js b/frontend/src/components/svgIcons/exit.js
deleted file mode 100644
index 7896c8cc8a..0000000000
--- a/frontend/src/components/svgIcons/exit.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ExitIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/exit.tsx b/frontend/src/components/svgIcons/exit.tsx
new file mode 100644
index 0000000000..bd3207a647
--- /dev/null
+++ b/frontend/src/components/svgIcons/exit.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const ExitIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/eye.js b/frontend/src/components/svgIcons/eye.js
deleted file mode 100644
index a3ffb40456..0000000000
--- a/frontend/src/components/svgIcons/eye.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class EyeIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/eye.tsx b/frontend/src/components/svgIcons/eye.tsx
new file mode 100644
index 0000000000..078a099e8d
--- /dev/null
+++ b/frontend/src/components/svgIcons/eye.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from "react";
+
+export const EyeIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/facebook.js b/frontend/src/components/svgIcons/facebook.js
deleted file mode 100644
index c5be8edb37..0000000000
--- a/frontend/src/components/svgIcons/facebook.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class FacebookIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/facebook.tsx b/frontend/src/components/svgIcons/facebook.tsx
new file mode 100644
index 0000000000..5b21b21a33
--- /dev/null
+++ b/frontend/src/components/svgIcons/facebook.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const FacebookIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/fileImport.js b/frontend/src/components/svgIcons/fileImport.js
deleted file mode 100644
index bb4413e205..0000000000
--- a/frontend/src/components/svgIcons/fileImport.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class FileImportIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/fileImport.tsx b/frontend/src/components/svgIcons/fileImport.tsx
new file mode 100644
index 0000000000..684ebeb6a1
--- /dev/null
+++ b/frontend/src/components/svgIcons/fileImport.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const FileImportIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/flag.js b/frontend/src/components/svgIcons/flag.js
deleted file mode 100644
index bd99a94af7..0000000000
--- a/frontend/src/components/svgIcons/flag.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class FlagIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/flag.tsx b/frontend/src/components/svgIcons/flag.tsx
new file mode 100644
index 0000000000..fa44754a97
--- /dev/null
+++ b/frontend/src/components/svgIcons/flag.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const FlagIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/fullscreen.js b/frontend/src/components/svgIcons/fullscreen.js
deleted file mode 100644
index 7119bdf953..0000000000
--- a/frontend/src/components/svgIcons/fullscreen.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class FullscreenIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/fullscreen.tsx b/frontend/src/components/svgIcons/fullscreen.tsx
new file mode 100644
index 0000000000..7f1d6f423d
--- /dev/null
+++ b/frontend/src/components/svgIcons/fullscreen.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const FullscreenIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/github.js b/frontend/src/components/svgIcons/github.js
deleted file mode 100644
index 414e6405ca..0000000000
--- a/frontend/src/components/svgIcons/github.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class GithubIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/github.tsx b/frontend/src/components/svgIcons/github.tsx
new file mode 100644
index 0000000000..378fdd3aa9
--- /dev/null
+++ b/frontend/src/components/svgIcons/github.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const GithubIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/grid.js b/frontend/src/components/svgIcons/grid.js
deleted file mode 100644
index f35209eb6e..0000000000
--- a/frontend/src/components/svgIcons/grid.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icons produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-
-export class FourCellsGridIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
-
-export class NineCellsGridIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
-
-export class FilledNineCellsGridIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/grid.tsx b/frontend/src/components/svgIcons/grid.tsx
new file mode 100644
index 0000000000..e2610249e4
--- /dev/null
+++ b/frontend/src/components/svgIcons/grid.tsx
@@ -0,0 +1,65 @@
+import { HTMLProps } from 'react';
+
+// Icons produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const FourCellsGridIcon = (props: HTMLProps) => (
+
+
+
+);
+
+export const NineCellsGridIcon = (props: HTMLProps) => (
+
+
+
+);
+
+export const FilledNineCellsGridIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/grip.js b/frontend/src/components/svgIcons/grip.js
deleted file mode 100644
index 18b8784f3b..0000000000
--- a/frontend/src/components/svgIcons/grip.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class GripIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/grip.tsx b/frontend/src/components/svgIcons/grip.tsx
new file mode 100644
index 0000000000..b114edc1ad
--- /dev/null
+++ b/frontend/src/components/svgIcons/grip.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const GripIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/hashtag.js b/frontend/src/components/svgIcons/hashtag.js
deleted file mode 100644
index 60dd5710b6..0000000000
--- a/frontend/src/components/svgIcons/hashtag.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class HashtagIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/hashtag.tsx b/frontend/src/components/svgIcons/hashtag.tsx
new file mode 100644
index 0000000000..87914e2275
--- /dev/null
+++ b/frontend/src/components/svgIcons/hashtag.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from "react";
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const HashtagIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/health.js b/frontend/src/components/svgIcons/health.js
deleted file mode 100644
index f213211a98..0000000000
--- a/frontend/src/components/svgIcons/health.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { PureComponent } from 'react';
-
-export class HealthIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/health.tsx b/frontend/src/components/svgIcons/health.tsx
new file mode 100644
index 0000000000..17e6b73721
--- /dev/null
+++ b/frontend/src/components/svgIcons/health.tsx
@@ -0,0 +1,22 @@
+import { HTMLProps } from "react";
+
+export const HealthIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/home.js b/frontend/src/components/svgIcons/home.js
deleted file mode 100644
index 1518aa9c72..0000000000
--- a/frontend/src/components/svgIcons/home.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class HomeIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/home.tsx b/frontend/src/components/svgIcons/home.tsx
new file mode 100644
index 0000000000..a3a6ae8a28
--- /dev/null
+++ b/frontend/src/components/svgIcons/home.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const HomeIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/humanProcessing.js b/frontend/src/components/svgIcons/humanProcessing.js
deleted file mode 100644
index 8d3fb26799..0000000000
--- a/frontend/src/components/svgIcons/humanProcessing.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { PureComponent } from 'react';
-
-export class HumanProcessingIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/humanProcessing.tsx b/frontend/src/components/svgIcons/humanProcessing.tsx
new file mode 100644
index 0000000000..be9e2286bb
--- /dev/null
+++ b/frontend/src/components/svgIcons/humanProcessing.tsx
@@ -0,0 +1,54 @@
+import { HTMLProps } from 'react';
+
+export const HumanProcessingIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/index.js b/frontend/src/components/svgIcons/index.ts
similarity index 100%
rename from frontend/src/components/svgIcons/index.js
rename to frontend/src/components/svgIcons/index.ts
diff --git a/frontend/src/components/svgIcons/info.js b/frontend/src/components/svgIcons/info.js
deleted file mode 100644
index 7853450b73..0000000000
--- a/frontend/src/components/svgIcons/info.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class InfoIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/info.tsx b/frontend/src/components/svgIcons/info.tsx
new file mode 100644
index 0000000000..98aa62e388
--- /dev/null
+++ b/frontend/src/components/svgIcons/info.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const InfoIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/instagram.js b/frontend/src/components/svgIcons/instagram.js
deleted file mode 100644
index 1cf654f5b4..0000000000
--- a/frontend/src/components/svgIcons/instagram.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { PureComponent } from 'react';
-
-export class InstagramIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/instagram.tsx b/frontend/src/components/svgIcons/instagram.tsx
new file mode 100644
index 0000000000..5c8907c077
--- /dev/null
+++ b/frontend/src/components/svgIcons/instagram.tsx
@@ -0,0 +1,19 @@
+import { HTMLProps } from 'react';
+
+export const InstagramIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/invalidated.js b/frontend/src/components/svgIcons/invalidated.js
deleted file mode 100644
index e76992c8eb..0000000000
--- a/frontend/src/components/svgIcons/invalidated.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PureComponent } from 'react';
-
-export class InvalidatedIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/invalidated.tsx b/frontend/src/components/svgIcons/invalidated.tsx
new file mode 100644
index 0000000000..1ece405ef1
--- /dev/null
+++ b/frontend/src/components/svgIcons/invalidated.tsx
@@ -0,0 +1,13 @@
+import { HTMLProps } from 'react';
+
+export const InvalidatedIcon = (props: HTMLProps) => (
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/left.js b/frontend/src/components/svgIcons/left.js
deleted file mode 100644
index f15c72fce7..0000000000
--- a/frontend/src/components/svgIcons/left.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class LeftIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/left.tsx b/frontend/src/components/svgIcons/left.tsx
new file mode 100644
index 0000000000..11abb93e2d
--- /dev/null
+++ b/frontend/src/components/svgIcons/left.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const LeftIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/link.js b/frontend/src/components/svgIcons/link.js
deleted file mode 100644
index 070059d3e9..0000000000
--- a/frontend/src/components/svgIcons/link.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ExternalLinkIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
-
-export class InternalLinkIcon extends PureComponent {
- // Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
- // License: CC-By 4.0
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/link.tsx b/frontend/src/components/svgIcons/link.tsx
new file mode 100644
index 0000000000..c51dd1d5d9
--- /dev/null
+++ b/frontend/src/components/svgIcons/link.tsx
@@ -0,0 +1,21 @@
+import { HTMLProps } from "react";
+
+export const ExternalLinkIcon = (props: HTMLProps) => (
+
+
+
+);
+
+export const InternalLinkIcon = (props: HTMLProps) => (
+ // Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+ // License: CC-By 4.0
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/linkedin.js b/frontend/src/components/svgIcons/linkedin.js
deleted file mode 100644
index dab9b14021..0000000000
--- a/frontend/src/components/svgIcons/linkedin.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class LinkedinIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/linkedin.tsx b/frontend/src/components/svgIcons/linkedin.tsx
new file mode 100644
index 0000000000..dd4a1c1976
--- /dev/null
+++ b/frontend/src/components/svgIcons/linkedin.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const LinkedinIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/list.js b/frontend/src/components/svgIcons/list.js
deleted file mode 100644
index c021484125..0000000000
--- a/frontend/src/components/svgIcons/list.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ListIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/list.tsx b/frontend/src/components/svgIcons/list.tsx
new file mode 100644
index 0000000000..67c58b6db8
--- /dev/null
+++ b/frontend/src/components/svgIcons/list.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from 'react';
+
+export const ListIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/loading.js b/frontend/src/components/svgIcons/loading.js
deleted file mode 100644
index 3a28a9afae..0000000000
--- a/frontend/src/components/svgIcons/loading.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class LoadingIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/loading.tsx b/frontend/src/components/svgIcons/loading.tsx
new file mode 100644
index 0000000000..a9d22644bf
--- /dev/null
+++ b/frontend/src/components/svgIcons/loading.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const LoadingIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/lock.js b/frontend/src/components/svgIcons/lock.js
deleted file mode 100644
index 4a531a0c1b..0000000000
--- a/frontend/src/components/svgIcons/lock.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class LockIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/lock.tsx b/frontend/src/components/svgIcons/lock.tsx
new file mode 100644
index 0000000000..5f4d717cee
--- /dev/null
+++ b/frontend/src/components/svgIcons/lock.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from "react";
+
+export const LockIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/manage.js b/frontend/src/components/svgIcons/manage.js
deleted file mode 100644
index be01874822..0000000000
--- a/frontend/src/components/svgIcons/manage.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ManageIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-export class GearIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/manage.tsx b/frontend/src/components/svgIcons/manage.tsx
new file mode 100644
index 0000000000..910f9a6373
--- /dev/null
+++ b/frontend/src/components/svgIcons/manage.tsx
@@ -0,0 +1,38 @@
+import { HTMLProps } from 'react';
+
+export const ManageIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+);
+
+export const GearIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/map.js b/frontend/src/components/svgIcons/map.js
deleted file mode 100644
index dfd1bdb1bc..0000000000
--- a/frontend/src/components/svgIcons/map.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class MapIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/map.tsx b/frontend/src/components/svgIcons/map.tsx
new file mode 100644
index 0000000000..3f1424d69d
--- /dev/null
+++ b/frontend/src/components/svgIcons/map.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const MapIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/mapped.js b/frontend/src/components/svgIcons/mapped.js
deleted file mode 100644
index 0177ac226d..0000000000
--- a/frontend/src/components/svgIcons/mapped.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import { PureComponent } from 'react';
-
-export class MappedIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/mapped.tsx b/frontend/src/components/svgIcons/mapped.tsx
new file mode 100644
index 0000000000..ad9386ca4f
--- /dev/null
+++ b/frontend/src/components/svgIcons/mapped.tsx
@@ -0,0 +1,48 @@
+import { HTMLProps } from 'react';
+
+export const MappedIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/mappedSquare.js b/frontend/src/components/svgIcons/mappedSquare.js
deleted file mode 100644
index f9e060e1ad..0000000000
--- a/frontend/src/components/svgIcons/mappedSquare.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { PureComponent } from 'react';
-
-export class MappedSquareIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/mappedSquare.tsx b/frontend/src/components/svgIcons/mappedSquare.tsx
new file mode 100644
index 0000000000..5d6bbfacbc
--- /dev/null
+++ b/frontend/src/components/svgIcons/mappedSquare.tsx
@@ -0,0 +1,69 @@
+import { HTMLProps } from 'react';
+
+export const MappedSquareIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/mapping.js b/frontend/src/components/svgIcons/mapping.js
deleted file mode 100644
index 1a24961ac7..0000000000
--- a/frontend/src/components/svgIcons/mapping.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { PureComponent } from 'react';
-
-export class MappingIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/mapping.tsx b/frontend/src/components/svgIcons/mapping.tsx
new file mode 100644
index 0000000000..b8b3fe9608
--- /dev/null
+++ b/frontend/src/components/svgIcons/mapping.tsx
@@ -0,0 +1,27 @@
+import { HTMLProps } from 'react';
+
+export const MappingIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/marker.js b/frontend/src/components/svgIcons/marker.js
deleted file mode 100644
index 7df91944a6..0000000000
--- a/frontend/src/components/svgIcons/marker.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class MarkerIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/marker.tsx b/frontend/src/components/svgIcons/marker.tsx
new file mode 100644
index 0000000000..c462463665
--- /dev/null
+++ b/frontend/src/components/svgIcons/marker.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const MarkerIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/menu.js b/frontend/src/components/svgIcons/menu.js
deleted file mode 100644
index 821f584d17..0000000000
--- a/frontend/src/components/svgIcons/menu.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class MenuIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/menu.tsx b/frontend/src/components/svgIcons/menu.tsx
new file mode 100644
index 0000000000..4d7f6bd0be
--- /dev/null
+++ b/frontend/src/components/svgIcons/menu.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from 'react';
+
+export const MenuIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/pencil.js b/frontend/src/components/svgIcons/pencil.js
deleted file mode 100644
index 5357ba5dc1..0000000000
--- a/frontend/src/components/svgIcons/pencil.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class PencilIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/pencil.tsx b/frontend/src/components/svgIcons/pencil.tsx
new file mode 100644
index 0000000000..65a3875eb2
--- /dev/null
+++ b/frontend/src/components/svgIcons/pencil.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from 'react';
+
+export const PencilIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/people.js b/frontend/src/components/svgIcons/people.js
deleted file mode 100644
index 5c227b77ac..0000000000
--- a/frontend/src/components/svgIcons/people.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class PeopleIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/people.tsx b/frontend/src/components/svgIcons/people.tsx
new file mode 100644
index 0000000000..ef8b8b659d
--- /dev/null
+++ b/frontend/src/components/svgIcons/people.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const PeopleIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/play.js b/frontend/src/components/svgIcons/play.js
deleted file mode 100644
index 46ed3700ff..0000000000
--- a/frontend/src/components/svgIcons/play.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class PlayIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/play.tsx b/frontend/src/components/svgIcons/play.tsx
new file mode 100644
index 0000000000..f0bb13c03f
--- /dev/null
+++ b/frontend/src/components/svgIcons/play.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from "react";
+
+export const PlayIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/plus.js b/frontend/src/components/svgIcons/plus.js
deleted file mode 100644
index a0176a707f..0000000000
--- a/frontend/src/components/svgIcons/plus.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class PlusIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/plus.tsx b/frontend/src/components/svgIcons/plus.tsx
new file mode 100644
index 0000000000..0333d35f57
--- /dev/null
+++ b/frontend/src/components/svgIcons/plus.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const PlusIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/polygon.js b/frontend/src/components/svgIcons/polygon.js
deleted file mode 100644
index f6877e5b29..0000000000
--- a/frontend/src/components/svgIcons/polygon.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { PureComponent } from 'react';
-
-export class PolygonIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/polygon.tsx b/frontend/src/components/svgIcons/polygon.tsx
new file mode 100644
index 0000000000..c32e4d7612
--- /dev/null
+++ b/frontend/src/components/svgIcons/polygon.tsx
@@ -0,0 +1,50 @@
+import { HTMLProps } from 'react';
+
+export const PolygonIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/profilePicture.js b/frontend/src/components/svgIcons/profilePicture.js
deleted file mode 100644
index 9b29941f20..0000000000
--- a/frontend/src/components/svgIcons/profilePicture.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ProfilePictureIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
- );
- }
-}
-
-export class UserIcon extends PureComponent {
- render() {
- return (
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/profilePicture.tsx b/frontend/src/components/svgIcons/profilePicture.tsx
new file mode 100644
index 0000000000..47e50ef85e
--- /dev/null
+++ b/frontend/src/components/svgIcons/profilePicture.tsx
@@ -0,0 +1,63 @@
+import { HTMLProps } from 'react';
+
+export const ProfilePictureIcon = (props: HTMLProps) => (
+
+
+
+
+
+);
+
+export const UserIcon = (props: HTMLProps) => (
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/projectSelection.js b/frontend/src/components/svgIcons/projectSelection.js
deleted file mode 100644
index 6b0efc9f96..0000000000
--- a/frontend/src/components/svgIcons/projectSelection.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ProjectSelectionIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/projectSelection.tsx b/frontend/src/components/svgIcons/projectSelection.tsx
new file mode 100644
index 0000000000..2318868c65
--- /dev/null
+++ b/frontend/src/components/svgIcons/projectSelection.tsx
@@ -0,0 +1,18 @@
+import { HTMLProps } from 'react';
+
+export const ProjectSelectionIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/questionCircle.js b/frontend/src/components/svgIcons/questionCircle.js
deleted file mode 100644
index a775cb612c..0000000000
--- a/frontend/src/components/svgIcons/questionCircle.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class QuestionCircleIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/questionCircle.tsx b/frontend/src/components/svgIcons/questionCircle.tsx
new file mode 100644
index 0000000000..ead29557d5
--- /dev/null
+++ b/frontend/src/components/svgIcons/questionCircle.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const QuestionCircleIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/refresh.js b/frontend/src/components/svgIcons/refresh.js
deleted file mode 100644
index 3e35b8b9b0..0000000000
--- a/frontend/src/components/svgIcons/refresh.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class RefreshIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/refresh.tsx b/frontend/src/components/svgIcons/refresh.tsx
new file mode 100644
index 0000000000..74bc9ba443
--- /dev/null
+++ b/frontend/src/components/svgIcons/refresh.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const RefreshIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/refugeeResponse.js b/frontend/src/components/svgIcons/refugeeResponse.js
deleted file mode 100644
index 1167f6c562..0000000000
--- a/frontend/src/components/svgIcons/refugeeResponse.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { PureComponent } from 'react';
-
-export class RefugeeResponseIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/refugeeResponse.tsx b/frontend/src/components/svgIcons/refugeeResponse.tsx
new file mode 100644
index 0000000000..9db5682e66
--- /dev/null
+++ b/frontend/src/components/svgIcons/refugeeResponse.tsx
@@ -0,0 +1,27 @@
+import { HTMLProps } from 'react';
+
+export const RefugeeResponseIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/resume.js b/frontend/src/components/svgIcons/resume.js
deleted file mode 100644
index 77be64bbbc..0000000000
--- a/frontend/src/components/svgIcons/resume.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ResumeIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/resume.tsx b/frontend/src/components/svgIcons/resume.tsx
new file mode 100644
index 0000000000..732108cd40
--- /dev/null
+++ b/frontend/src/components/svgIcons/resume.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const ResumeIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/right.js b/frontend/src/components/svgIcons/right.js
deleted file mode 100644
index cd53ed9917..0000000000
--- a/frontend/src/components/svgIcons/right.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class RightIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/right.tsx b/frontend/src/components/svgIcons/right.tsx
new file mode 100644
index 0000000000..fb6420908d
--- /dev/null
+++ b/frontend/src/components/svgIcons/right.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const RightIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/road.js b/frontend/src/components/svgIcons/road.js
deleted file mode 100644
index c403900a23..0000000000
--- a/frontend/src/components/svgIcons/road.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { PureComponent } from 'react';
-
-export class RoadIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/road.tsx b/frontend/src/components/svgIcons/road.tsx
new file mode 100644
index 0000000000..00f8a6d9ed
--- /dev/null
+++ b/frontend/src/components/svgIcons/road.tsx
@@ -0,0 +1,19 @@
+import { HTMLProps } from "react";
+
+export const RoadIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/search.js b/frontend/src/components/svgIcons/search.js
deleted file mode 100644
index d46b8d87db..0000000000
--- a/frontend/src/components/svgIcons/search.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SearchIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/search.tsx b/frontend/src/components/svgIcons/search.tsx
new file mode 100644
index 0000000000..3d49066a29
--- /dev/null
+++ b/frontend/src/components/svgIcons/search.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from 'react';
+
+export const SearchIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/selectProject.js b/frontend/src/components/svgIcons/selectProject.js
deleted file mode 100644
index 9e56c87593..0000000000
--- a/frontend/src/components/svgIcons/selectProject.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SelectProject extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/selectProject.tsx b/frontend/src/components/svgIcons/selectProject.tsx
new file mode 100644
index 0000000000..ece993adde
--- /dev/null
+++ b/frontend/src/components/svgIcons/selectProject.tsx
@@ -0,0 +1,27 @@
+import { HTMLProps } from "react";
+
+export const SelectProject = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/selectTask.js b/frontend/src/components/svgIcons/selectTask.js
deleted file mode 100644
index 5703bda722..0000000000
--- a/frontend/src/components/svgIcons/selectTask.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SelectTask extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/selectTask.tsx b/frontend/src/components/svgIcons/selectTask.tsx
new file mode 100644
index 0000000000..15ed36789c
--- /dev/null
+++ b/frontend/src/components/svgIcons/selectTask.tsx
@@ -0,0 +1,29 @@
+import { HTMLProps } from "react";
+
+export const SelectTask = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/settings.js b/frontend/src/components/svgIcons/settings.js
deleted file mode 100644
index 326deb65ff..0000000000
--- a/frontend/src/components/svgIcons/settings.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SettingsIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/settings.tsx b/frontend/src/components/svgIcons/settings.tsx
new file mode 100644
index 0000000000..a1aaebdd5d
--- /dev/null
+++ b/frontend/src/components/svgIcons/settings.tsx
@@ -0,0 +1,11 @@
+import { HTMLProps } from "react";
+
+export const SettingsIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/share.js b/frontend/src/components/svgIcons/share.js
deleted file mode 100644
index d6a2da83d5..0000000000
--- a/frontend/src/components/svgIcons/share.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ShareIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/share.tsx b/frontend/src/components/svgIcons/share.tsx
new file mode 100644
index 0000000000..dfa8ef92ec
--- /dev/null
+++ b/frontend/src/components/svgIcons/share.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const ShareIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/sidebar.js b/frontend/src/components/svgIcons/sidebar.js
deleted file mode 100644
index 0a911f0eef..0000000000
--- a/frontend/src/components/svgIcons/sidebar.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SidebarIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/sidebar.tsx b/frontend/src/components/svgIcons/sidebar.tsx
new file mode 100644
index 0000000000..e7fbedd105
--- /dev/null
+++ b/frontend/src/components/svgIcons/sidebar.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const SidebarIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/star.js b/frontend/src/components/svgIcons/star.js
deleted file mode 100644
index 4c68e128cd..0000000000
--- a/frontend/src/components/svgIcons/star.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icons produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class FullStarIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
-
-export class StarIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
-
-export class HalfStarIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/star.tsx b/frontend/src/components/svgIcons/star.tsx
new file mode 100644
index 0000000000..cca215e878
--- /dev/null
+++ b/frontend/src/components/svgIcons/star.tsx
@@ -0,0 +1,30 @@
+import { HTMLProps } from "react";
+
+// Icons produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const FullStarIcon = (props: HTMLProps) => (
+
+
+
+);
+
+export const StarIcon = (props: HTMLProps) => (
+
+
+
+);
+
+export const HalfStarIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/submitWork.js b/frontend/src/components/svgIcons/submitWork.js
deleted file mode 100644
index ab94858ea5..0000000000
--- a/frontend/src/components/svgIcons/submitWork.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SubmitWorkIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/submitWork.tsx b/frontend/src/components/svgIcons/submitWork.tsx
new file mode 100644
index 0000000000..7d065ba20d
--- /dev/null
+++ b/frontend/src/components/svgIcons/submitWork.tsx
@@ -0,0 +1,17 @@
+import { HTMLProps } from 'react';
+
+export const SubmitWorkIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/swipe.js b/frontend/src/components/svgIcons/swipe.js
deleted file mode 100644
index 0d313f154f..0000000000
--- a/frontend/src/components/svgIcons/swipe.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { PureComponent } from 'react';
-
-export class SwipeIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/swipe.tsx b/frontend/src/components/svgIcons/swipe.tsx
new file mode 100644
index 0000000000..0604195f57
--- /dev/null
+++ b/frontend/src/components/svgIcons/swipe.tsx
@@ -0,0 +1,27 @@
+import { HTMLProps } from 'react';
+
+export const SwipeIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/tableList.js b/frontend/src/components/svgIcons/tableList.js
deleted file mode 100644
index e6af0a4c40..0000000000
--- a/frontend/src/components/svgIcons/tableList.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PureComponent } from 'react';
-
-export class TableListIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/tableList.tsx b/frontend/src/components/svgIcons/tableList.tsx
new file mode 100644
index 0000000000..096d2a3a8f
--- /dev/null
+++ b/frontend/src/components/svgIcons/tableList.tsx
@@ -0,0 +1,10 @@
+import { HTMLProps } from 'react';
+
+export const TableListIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/task.js b/frontend/src/components/svgIcons/task.js
deleted file mode 100644
index 865cdd8b83..0000000000
--- a/frontend/src/components/svgIcons/task.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PureComponent } from 'react';
-
-export class TaskIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/task.tsx b/frontend/src/components/svgIcons/task.tsx
new file mode 100644
index 0000000000..6933da91b9
--- /dev/null
+++ b/frontend/src/components/svgIcons/task.tsx
@@ -0,0 +1,13 @@
+import { HTMLProps } from "react";
+
+export const TaskIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/taskSelection.js b/frontend/src/components/svgIcons/taskSelection.js
deleted file mode 100644
index 833af1d386..0000000000
--- a/frontend/src/components/svgIcons/taskSelection.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { PureComponent } from 'react';
-
-export class TaskSelectionIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/taskSelection.tsx b/frontend/src/components/svgIcons/taskSelection.tsx
new file mode 100644
index 0000000000..e5c15942e7
--- /dev/null
+++ b/frontend/src/components/svgIcons/taskSelection.tsx
@@ -0,0 +1,21 @@
+import { HTMLProps } from 'react';
+
+export const TaskSelectionIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/timer.js b/frontend/src/components/svgIcons/timer.js
deleted file mode 100644
index 517d368adf..0000000000
--- a/frontend/src/components/svgIcons/timer.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { PureComponent } from 'react';
-
-export class TimerIcon extends PureComponent {
- render() {
- return (
-
- Timer
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/timer.tsx b/frontend/src/components/svgIcons/timer.tsx
new file mode 100644
index 0000000000..5f02fb957c
--- /dev/null
+++ b/frontend/src/components/svgIcons/timer.tsx
@@ -0,0 +1,21 @@
+import { HTMLProps } from 'react';
+
+export const TimerIcon = (props: HTMLProps) => (
+
+ Timer
+
+
+);
diff --git a/frontend/src/components/svgIcons/twitter.js b/frontend/src/components/svgIcons/twitter.js
deleted file mode 100644
index 19ff3efaf1..0000000000
--- a/frontend/src/components/svgIcons/twitter.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { PureComponent } from 'react';
-
-export class TwitterIcon extends PureComponent {
- render() {
- return (
-
- {!this.props.noBg && }
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/twitter.tsx b/frontend/src/components/svgIcons/twitter.tsx
new file mode 100644
index 0000000000..4a89941276
--- /dev/null
+++ b/frontend/src/components/svgIcons/twitter.tsx
@@ -0,0 +1,27 @@
+import { HTMLProps } from 'react';
+
+export const TwitterIcon = (
+ props: HTMLProps & {
+ noBg?: boolean;
+ },
+) => {
+ const { noBg, ...rest } = props;
+
+ return (
+
+ {!noBg && }
+
+
+ );
+};
diff --git a/frontend/src/components/svgIcons/undo.js b/frontend/src/components/svgIcons/undo.js
deleted file mode 100644
index ee52ad6890..0000000000
--- a/frontend/src/components/svgIcons/undo.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class UndoIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/undo.tsx b/frontend/src/components/svgIcons/undo.tsx
new file mode 100644
index 0000000000..5cc7a45c20
--- /dev/null
+++ b/frontend/src/components/svgIcons/undo.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const UndoIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/validated.js b/frontend/src/components/svgIcons/validated.js
deleted file mode 100644
index bafb4964cf..0000000000
--- a/frontend/src/components/svgIcons/validated.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ValidatedIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/validated.tsx b/frontend/src/components/svgIcons/validated.tsx
new file mode 100644
index 0000000000..c35e297483
--- /dev/null
+++ b/frontend/src/components/svgIcons/validated.tsx
@@ -0,0 +1,13 @@
+import { HTMLProps } from 'react';
+
+export const ValidatedIcon = (props: HTMLProps) => (
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/validation.js b/frontend/src/components/svgIcons/validation.js
deleted file mode 100644
index 7d3312ed01..0000000000
--- a/frontend/src/components/svgIcons/validation.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ValidationIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/validation.tsx b/frontend/src/components/svgIcons/validation.tsx
new file mode 100644
index 0000000000..7932ed92ce
--- /dev/null
+++ b/frontend/src/components/svgIcons/validation.tsx
@@ -0,0 +1,28 @@
+import { HTMLProps } from 'react';
+
+export const ValidationIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/view.js b/frontend/src/components/svgIcons/view.js
deleted file mode 100644
index ef2ac16528..0000000000
--- a/frontend/src/components/svgIcons/view.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ViewIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/view.tsx b/frontend/src/components/svgIcons/view.tsx
new file mode 100644
index 0000000000..d36210cd0d
--- /dev/null
+++ b/frontend/src/components/svgIcons/view.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const ViewIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/waste.js b/frontend/src/components/svgIcons/waste.js
deleted file mode 100644
index 62e075193a..0000000000
--- a/frontend/src/components/svgIcons/waste.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class WasteIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/waste.tsx b/frontend/src/components/svgIcons/waste.tsx
new file mode 100644
index 0000000000..c6ab05e19c
--- /dev/null
+++ b/frontend/src/components/svgIcons/waste.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const WasteIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/waterSanitation.js b/frontend/src/components/svgIcons/waterSanitation.js
deleted file mode 100644
index 305ccfac5d..0000000000
--- a/frontend/src/components/svgIcons/waterSanitation.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { PureComponent } from 'react';
-
-export class WaterSanitationIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/waterSanitation.tsx b/frontend/src/components/svgIcons/waterSanitation.tsx
new file mode 100644
index 0000000000..b636236f02
--- /dev/null
+++ b/frontend/src/components/svgIcons/waterSanitation.tsx
@@ -0,0 +1,24 @@
+import { HTMLProps } from 'react';
+
+export const WaterSanitationIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/waves.js b/frontend/src/components/svgIcons/waves.js
deleted file mode 100644
index b730b3acb2..0000000000
--- a/frontend/src/components/svgIcons/waves.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { PureComponent } from 'react';
-
-export class WavesIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/waves.tsx b/frontend/src/components/svgIcons/waves.tsx
new file mode 100644
index 0000000000..4c2fa954ba
--- /dev/null
+++ b/frontend/src/components/svgIcons/waves.tsx
@@ -0,0 +1,19 @@
+import { HTMLProps } from "react";
+
+export const WavesIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/worldNodes.js b/frontend/src/components/svgIcons/worldNodes.js
deleted file mode 100644
index cd5261b0ea..0000000000
--- a/frontend/src/components/svgIcons/worldNodes.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { PureComponent } from 'react';
-
-export class WorldNodesIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/worldNodes.tsx b/frontend/src/components/svgIcons/worldNodes.tsx
new file mode 100644
index 0000000000..13936c3694
--- /dev/null
+++ b/frontend/src/components/svgIcons/worldNodes.tsx
@@ -0,0 +1,20 @@
+import { HTMLProps } from 'react';
+
+export const WorldNodesIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/youtube.js b/frontend/src/components/svgIcons/youtube.js
deleted file mode 100644
index b33ddff541..0000000000
--- a/frontend/src/components/svgIcons/youtube.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react';
-
-export class YoutubeIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/youtube.tsx b/frontend/src/components/svgIcons/youtube.tsx
new file mode 100644
index 0000000000..501a3ac9f7
--- /dev/null
+++ b/frontend/src/components/svgIcons/youtube.tsx
@@ -0,0 +1,14 @@
+import { HTMLProps } from "react";
+
+export const YoutubeIcon = (props: HTMLProps) => (
+
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/zoomMinus.js b/frontend/src/components/svgIcons/zoomMinus.js
deleted file mode 100644
index 86628fd29b..0000000000
--- a/frontend/src/components/svgIcons/zoomMinus.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { PureComponent } from 'react';
-
-export class ZoomMinusIcon extends PureComponent {
- render() {
- return (
-
-
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/zoomMinus.tsx b/frontend/src/components/svgIcons/zoomMinus.tsx
new file mode 100644
index 0000000000..5822120f6f
--- /dev/null
+++ b/frontend/src/components/svgIcons/zoomMinus.tsx
@@ -0,0 +1,17 @@
+import { HTMLProps } from 'react';
+
+export const ZoomMinusIcon = (props: HTMLProps) => (
+
+
+
+
+
+);
diff --git a/frontend/src/components/svgIcons/zoomPlus.js b/frontend/src/components/svgIcons/zoomPlus.js
deleted file mode 100644
index 1d268b28cb..0000000000
--- a/frontend/src/components/svgIcons/zoomPlus.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PureComponent } from 'react';
-
-// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
-// License: CC-By 4.0
-export class ZoomPlusIcon extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
diff --git a/frontend/src/components/svgIcons/zoomPlus.tsx b/frontend/src/components/svgIcons/zoomPlus.tsx
new file mode 100644
index 0000000000..52aea57732
--- /dev/null
+++ b/frontend/src/components/svgIcons/zoomPlus.tsx
@@ -0,0 +1,12 @@
+import { HTMLProps } from 'react';
+
+// Icon produced by FontAwesome project: https://github.com/FortAwesome/Font-Awesome/
+// License: CC-By 4.0
+export const ZoomPlusIcon = (props: HTMLProps) => (
+
+
+
+);
diff --git a/frontend/src/components/taskSelection/action.js b/frontend/src/components/taskSelection/action.js
deleted file mode 100644
index 9b5166d282..0000000000
--- a/frontend/src/components/taskSelection/action.js
+++ /dev/null
@@ -1,488 +0,0 @@
-import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
-import { useSelector } from 'react-redux';
-import { useNavigate, useLocation } from 'react-router-dom';
-import ReactPlaceholder from 'react-placeholder';
-import Popup from 'reactjs-popup';
-import toast from 'react-hot-toast';
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import messages from './messages';
-import { ProjectInstructions } from './instructions';
-import { TasksMap } from './map';
-import { HeaderLine } from '../projectDetail/header';
-import { Button } from '../button';
-import Portal from '../portal';
-import { SidebarIcon } from '../svgIcons';
-import { openEditor, getTaskGpxUrl, formatImageryUrl, formatJosmUrl } from '../../utils/openEditor';
-import { getTaskContributors } from '../../utils/getTaskContributors';
-import { TaskHistory } from './taskActivity';
-import { ChangesetCommentTags } from './changesetComment';
-import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
-import { useReadTaskComments } from '../../hooks/UseReadTaskComments';
-import { useDisableBadImagery } from '../../hooks/UseDisableBadImagery';
-import { DueDateBox } from '../projectCard/dueDateBox';
-import {
- CompletionTabForMapping,
- CompletionTabForValidation,
- SidebarToggle,
- ReopenEditor,
- UnsavedMapChangesModalContent,
-} from './actionSidebars';
-import { MultipleTaskHistoriesAccordion } from './multipleTaskHistories';
-import { ResourcesTab } from './resourcesTab';
-import { ActionTabsNav } from './actionTabsNav';
-import { LockedTaskModalContent } from './lockedTasks';
-import { SessionAboutToExpire, SessionExpired } from './extendSession';
-import { MappingTypes } from '../mappingTypes';
-import { usePriorityAreasQuery, useTaskDetail } from '../../api/projects';
-
-const Editor = lazy(() => import('../editor'));
-const RapidEditor = lazy(() => import('../rapidEditor'));
-
-const MINUTES_BEFORE_DIALOG = 5;
-
-export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, editor }) {
- useSetProjectPageTitleTag(project);
- const intl = useIntl();
- const navigate = useNavigate();
- const location = useLocation();
- const aboutToExpireTimeoutRef = useRef();
- const expiredTimeoutRef = useRef();
- const userDetails = useSelector((state) => state.auth.userDetails);
- const token = useSelector((state) => state.auth.token);
- const [activeSection, setActiveSection] = useState('completion');
- const [activeEditor, setActiveEditor] = useState(editor);
- const [showSidebar, setShowSidebar] = useState(true);
- const [isJosmError, setIsJosmError] = useState(false);
- const tasksIds = useMemo(
- () =>
- activeTasks
- ? activeTasks
- .map((task) => task.taskId)
- .sort((n1, n2) => {
- // in ascending order
- return n1 - n2;
- })
- : [],
- [activeTasks],
- );
- const [disabled, setDisable] = useState(false);
- const [taskComment, setTaskComment] = useState('');
- const [selectedStatus, setSelectedStatus] = useState();
- const [validationComments, setValidationComments] = useState({});
- const [validationStatus, setValidationStatus] = useState({});
- const [historyTabChecked, setHistoryTabChecked] = useState(false);
- const [showMapChangesModal, setShowMapChangesModal] = useState(false);
- const [showSessionExpiringDialog, setShowSessionExpiringDialog] = useState(false);
- const [showSessionExpiredDialog, setSessionTimeExpiredDialog] = useState(false);
-
- const activeTask = activeTasks?.[0];
- const timer = new Date(activeTask.lastUpdated);
- timer.setSeconds(timer.getSeconds() + activeTask.autoUnlockSeconds);
- //eslint-disable-next-line
- const { data: taskDetail } = useTaskDetail(project.projectId, tasksIds[0]);
- const { data: priorityArea, isError: isPriorityAreaError } = usePriorityAreasQuery(
- project.projectId,
- );
-
- const contributors = taskDetail?.taskHistory
- ? getTaskContributors(taskDetail.taskHistory, userDetails.username)
- : [];
-
- const readTaskComments = useReadTaskComments(taskDetail);
- const disableBadImagery = useDisableBadImagery(taskDetail);
-
- const getTaskGpxUrlCallback = useCallback((project, tasks) => getTaskGpxUrl(project, tasks), []);
- const formatImageryUrlCallback = useCallback((imagery) => formatImageryUrl(imagery), []);
-
- const historyTabSwitch = () => {
- setHistoryTabChecked(true);
- setActiveSection('history');
- };
-
- useEffect(() => {
- const tempTimer = new Date(activeTask.lastUpdated);
- tempTimer.setSeconds(tempTimer.getSeconds() + activeTask.autoUnlockSeconds);
- const milliDifferenceForSessionExpire = new Date(tempTimer) - Date.now();
- const milliDifferenceForAboutToSessionExpire =
- milliDifferenceForSessionExpire - MINUTES_BEFORE_DIALOG * 60 * 1000;
-
- aboutToExpireTimeoutRef.current = setTimeout(() => {
- setSessionTimeExpiredDialog(false);
- setShowSessionExpiringDialog(true);
- }, milliDifferenceForAboutToSessionExpire);
-
- expiredTimeoutRef.current = setTimeout(() => {
- setShowSessionExpiringDialog(false);
- setSessionTimeExpiredDialog(true);
- }, milliDifferenceForSessionExpire);
-
- return () => {
- clearTimeout(aboutToExpireTimeoutRef.current);
- clearTimeout(expiredTimeoutRef.current);
- };
- }, [activeTask.autoUnlockSeconds, activeTask.lastUpdated]);
-
- useEffect(() => {
- if (!editor && userDetails.defaultEditor && tasks && tasksIds) {
- let editorToUse;
- if (action === 'MAPPING') {
- editorToUse = project.mappingEditors.includes(userDetails.defaultEditor)
- ? [userDetails.defaultEditor]
- : project.mappingEditors;
- } else {
- editorToUse = project.validationEditors.includes(userDetails.defaultEditor)
- ? [userDetails.defaultEditor]
- : project.validationEditors;
- }
- const url = openEditor(
- editorToUse[0],
- project,
- tasks,
- tasksIds,
- [window.innerWidth, window.innerHeight],
- null,
- );
-
- if (url) {
- navigate(`./${url}`);
- } else {
- navigate(`./?editor=${editorToUse[0]}`);
- }
- }
- }, [editor, project, userDetails.defaultEditor, action, tasks, tasksIds, navigate]);
-
- useEffect(() => {
- if (location.state?.directedFrom) {
- localStorage.setItem('lastProjectPathname', location.state.directedFrom);
- } else {
- localStorage.removeItem('lastProjectPathname');
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- isPriorityAreaError &&
- !['ID', 'RAPID'].includes(editor) &&
- toast.error( );
- }, [editor, isPriorityAreaError]);
-
- const callEditor = async (arr) => {
- setIsJosmError(false);
- if (!disabled) {
- setActiveEditor(arr[0].value);
- const url = openEditor(
- arr[0].value,
- project,
- tasks,
- tasksIds,
- [window.innerWidth, window.innerHeight],
- null,
- );
- if (url) {
- navigate(`./${url}`);
- if (arr[0].value === 'JOSM') {
- try {
- await fetch(formatJosmUrl('version', { jsonp: 'checkJOSM' }));
- } catch (e) {
- setIsJosmError(true);
- return;
- }
- }
- } else {
- navigate(`./?editor=${arr[0].value}`);
- }
- } else {
- // we need to return a promise in order to be called by useAsync
- return new Promise((resolve, reject) => {
- setShowMapChangesModal('reload editor');
- resolve();
- });
- }
- };
-
- return (
- <>
-
-
-
- {['ID', 'RAPID'].includes(editor) ? (
-
-
-
- }
- >
- {editor === 'ID' ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- {showSidebar ? (
-
-
0}
- >
- {(activeEditor === 'ID' || activeEditor === 'RAPID') && (
-
- )}
-
-
-
- {project.projectInfo && project.projectInfo.name}
- ·
- {tasksIds.map((task, n) => (
-
- {`#${task}`}
- {tasksIds.length > 1 && n !== tasksIds.length - 1 ? (
- ·
- ) : (
- ''
- )}
-
- ))}
-
-
-
-
-
-
-
-
- {activeSection === 'completion' && (
- <>
- {action === 'MAPPING' && (
-
- )}
- {action === 'VALIDATION' && (
-
- )}
-
-
- {disabled && showMapChangesModal && (
-
setShowMapChangesModal(null)}
- >
- {(close) => (
-
- )}
-
- )}
- {(editor === 'ID' || editor === 'RAPID') && (
-
(
-
-
-
-
-
- )}
- closeOnEscape={true}
- closeOnDocumentClick={true}
- onOpen={() => {
- isPriorityAreaError &&
- toast.error(
- ,
- );
- }}
- >
- {(close) => (
-
-
-
- )}
-
- )}
-
- >
- )}
- {activeSection === 'instructions' && (
- <>
-
-
- >
- )}
- {activeSection === 'history' && (
- <>
- {activeTasks.length === 1 && (
- <>
-
- >
- )}
- {action === 'VALIDATION' && activeTasks.length > 1 && (
-
- )}
- >
- )}
- {activeSection === 'resources' && (
-
- )}
-
-
-
- ) : (
- setShowSidebar(true)}
- >
-
- {(msg) => (
-
-
-
- )}
-
-
-
#{project.projectId}
-
- {tasksIds.map((task, n) => (
- {`#${task}`}
- ))}
-
-
-
- )}
-
- >
- );
-}
diff --git a/frontend/src/components/taskSelection/action.jsx b/frontend/src/components/taskSelection/action.jsx
new file mode 100644
index 0000000000..1c30e0ecce
--- /dev/null
+++ b/frontend/src/components/taskSelection/action.jsx
@@ -0,0 +1,488 @@
+import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
+import { useTypedSelector } from '@Store/hooks';
+import { useNavigate, useLocation } from 'react-router-dom';
+import ReactPlaceholder from 'react-placeholder';
+import Popup from 'reactjs-popup';
+import toast from 'react-hot-toast';
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import messages from './messages';
+import { ProjectInstructions } from './instructions';
+import { TasksMap } from './map';
+import { HeaderLine } from '../projectDetail/header';
+import { Button } from '../button';
+import Portal from '../portal';
+import { SidebarIcon } from '../svgIcons';
+import { openEditor, getTaskGpxUrl, formatImageryUrl, formatJosmUrl } from '../../utils/openEditor';
+import { getTaskContributors } from '../../utils/getTaskContributors';
+import { TaskHistory } from './taskActivity';
+import { ChangesetCommentTags } from './changesetComment';
+import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
+import { useReadTaskComments } from '../../hooks/UseReadTaskComments';
+import { useDisableBadImagery } from '../../hooks/UseDisableBadImagery';
+import { DueDateBox } from '../projectCard/dueDateBox';
+import {
+ CompletionTabForMapping,
+ CompletionTabForValidation,
+ SidebarToggle,
+ ReopenEditor,
+ UnsavedMapChangesModalContent,
+} from './actionSidebars';
+import { MultipleTaskHistoriesAccordion } from './multipleTaskHistories';
+import { ResourcesTab } from './resourcesTab';
+import { ActionTabsNav } from './actionTabsNav';
+import { LockedTaskModalContent } from './lockedTasks';
+import { SessionAboutToExpire, SessionExpired } from './extendSession';
+import { MappingTypes } from '../mappingTypes';
+import { usePriorityAreasQuery, useTaskDetail } from '../../api/projects';
+
+const Editor = lazy(() => import('../editor'));
+const RapidEditor = lazy(() => import('../rapidEditor'));
+
+const MINUTES_BEFORE_DIALOG = 5;
+
+export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, editor }) {
+ useSetProjectPageTitleTag(project);
+ const intl = useIntl();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const aboutToExpireTimeoutRef = useRef();
+ const expiredTimeoutRef = useRef();
+ const userDetails = useTypedSelector((state) => state.auth.userDetails);
+ const token = useTypedSelector((state) => state.auth.token);
+ const [activeSection, setActiveSection] = useState('completion');
+ const [activeEditor, setActiveEditor] = useState(editor);
+ const [showSidebar, setShowSidebar] = useState(true);
+ const [isJosmError, setIsJosmError] = useState(false);
+ const tasksIds = useMemo(
+ () =>
+ activeTasks
+ ? activeTasks
+ .map((task) => task.taskId)
+ .sort((n1, n2) => {
+ // in ascending order
+ return n1 - n2;
+ })
+ : [],
+ [activeTasks],
+ );
+ const [disabled, setDisable] = useState(false);
+ const [taskComment, setTaskComment] = useState('');
+ const [selectedStatus, setSelectedStatus] = useState();
+ const [validationComments, setValidationComments] = useState({});
+ const [validationStatus, setValidationStatus] = useState({});
+ const [historyTabChecked, setHistoryTabChecked] = useState(false);
+ const [showMapChangesModal, setShowMapChangesModal] = useState(false);
+ const [showSessionExpiringDialog, setShowSessionExpiringDialog] = useState(false);
+ const [showSessionExpiredDialog, setSessionTimeExpiredDialog] = useState(false);
+
+ const activeTask = activeTasks?.[0];
+ const timer = new Date(activeTask.lastUpdated);
+ timer.setSeconds(timer.getSeconds() + activeTask.autoUnlockSeconds);
+ //eslint-disable-next-line
+ const { data: taskDetail } = useTaskDetail(project.projectId, tasksIds[0]);
+ const { data: priorityArea, isError: isPriorityAreaError } = usePriorityAreasQuery(
+ project.projectId,
+ );
+
+ const contributors = taskDetail?.taskHistory
+ ? getTaskContributors(taskDetail.taskHistory, userDetails?.username)
+ : [];
+
+ const readTaskComments = useReadTaskComments(taskDetail);
+ const disableBadImagery = useDisableBadImagery(taskDetail);
+
+ const getTaskGpxUrlCallback = useCallback((project, tasks) => getTaskGpxUrl(project, tasks), []);
+ const formatImageryUrlCallback = useCallback((imagery) => formatImageryUrl(imagery), []);
+
+ const historyTabSwitch = () => {
+ setHistoryTabChecked(true);
+ setActiveSection('history');
+ };
+
+ useEffect(() => {
+ const tempTimer = new Date(activeTask.lastUpdated);
+ tempTimer.setSeconds(tempTimer.getSeconds() + activeTask.autoUnlockSeconds);
+ const milliDifferenceForSessionExpire = new Date(tempTimer) - Date.now();
+ const milliDifferenceForAboutToSessionExpire =
+ milliDifferenceForSessionExpire - MINUTES_BEFORE_DIALOG * 60 * 1000;
+
+ aboutToExpireTimeoutRef.current = setTimeout(() => {
+ setSessionTimeExpiredDialog(false);
+ setShowSessionExpiringDialog(true);
+ }, milliDifferenceForAboutToSessionExpire);
+
+ expiredTimeoutRef.current = setTimeout(() => {
+ setShowSessionExpiringDialog(false);
+ setSessionTimeExpiredDialog(true);
+ }, milliDifferenceForSessionExpire);
+
+ return () => {
+ clearTimeout(aboutToExpireTimeoutRef.current);
+ clearTimeout(expiredTimeoutRef.current);
+ };
+ }, [activeTask.autoUnlockSeconds, activeTask.lastUpdated]);
+
+ useEffect(() => {
+ if (!editor && userDetails?.defaultEditor && tasks && tasksIds) {
+ let editorToUse;
+ if (action === 'MAPPING') {
+ editorToUse = project.mappingEditors.includes(userDetails?.defaultEditor)
+ ? [userDetails?.defaultEditor]
+ : project.mappingEditors;
+ } else {
+ editorToUse = project.validationEditors.includes(userDetails?.defaultEditor)
+ ? [userDetails?.defaultEditor]
+ : project.validationEditors;
+ }
+ const url = openEditor(
+ editorToUse[0],
+ project,
+ tasks,
+ tasksIds,
+ [window.innerWidth, window.innerHeight],
+ null,
+ );
+
+ if (url) {
+ navigate(`./${url}`);
+ } else {
+ navigate(`./?editor=${editorToUse[0]}`);
+ }
+ }
+ }, [editor, project, userDetails?.defaultEditor, action, tasks, tasksIds, navigate]);
+
+ useEffect(() => {
+ if (location.state?.directedFrom) {
+ localStorage.setItem('lastProjectPathname', location.state.directedFrom);
+ } else {
+ localStorage.removeItem('lastProjectPathname');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ isPriorityAreaError &&
+ !['ID', 'RAPID'].includes(editor) &&
+ toast.error(
);
+ }, [editor, isPriorityAreaError]);
+
+ const callEditor = async (arr) => {
+ setIsJosmError(false);
+ if (!disabled) {
+ setActiveEditor(arr[0].value);
+ const url = openEditor(
+ arr[0].value,
+ project,
+ tasks,
+ tasksIds,
+ [window.innerWidth, window.innerHeight],
+ null,
+ );
+ if (url) {
+ navigate(`./${url}`);
+ if (arr[0].value === 'JOSM') {
+ try {
+ await fetch(formatJosmUrl('version', { jsonp: 'checkJOSM' }));
+ } catch {
+ setIsJosmError(true);
+ return;
+ }
+ }
+ } else {
+ navigate(`./?editor=${arr[0].value}`);
+ }
+ } else {
+ // we need to return a promise in order to be called by useAsync
+ return new Promise((resolve) => {
+ setShowMapChangesModal('reload editor');
+ resolve();
+ });
+ }
+ };
+
+ return (
+ <>
+