diff --git a/.circleci/config.yml b/.circleci/config.yml
index 0674170b83..a99c5403bf 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -33,7 +33,7 @@ jobs:
no_output_timeout: 20m
command: |
cd ${CIRCLE_WORKING_DIRECTORY}/frontend/
- CI=true yarn test -w 1 --silent
+ CI=true yarn test --silent
CI=true GENERATE_SOURCEMAP=false yarn build
backend-code-check-PEP8:
@@ -452,7 +452,7 @@ workflows:
value: << pipeline.git.branch >>
- or:
## - equal: [ develop, << pipeline.git.branch >> ] # Disabled while we use dev setup for e2e testing
- - equal: [ dev-switch-to-sandbox, << pipeline.git.branch >> ]
+ - equal: [ typescript-vite, << pipeline.git.branch >> ]
jobs:
- database-backup:
name: Backup development database
diff --git a/.gitignore b/.gitignore
index 556a135d51..98ae868166 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,7 @@ tasking-manager*.env
# Generated frontend #
frontend/node_modules/
-frontend/build/
+frontend/dist/
frontend/public/static/
frontend/assets/styles/
frontend/package-lock.json
diff --git a/frontend/.env.expand b/frontend/.env.expand
index 314dcf6193..e4dbf4b6e3 100644
--- a/frontend/.env.expand
+++ b/frontend/.env.expand
@@ -2,47 +2,47 @@
# Variables that are available for the frontend.
# Usually doesn't need to be modified
#
-REACT_APP_BASE_URL=$TM_APP_BASE_URL
-REACT_APP_API_URL=$TM_APP_API_URL
-REACT_APP_API_VERSION=$TM_APP_API_VERSION
-REACT_APP_ORG_NAME=$TM_ORG_NAME
-REACT_APP_ORG_CODE=$TM_ORG_CODE
-REACT_APP_ORG_URL=$TM_ORG_URL
-REACT_APP_ORG_LOGO=$TM_ORG_LOGO
-REACT_APP_ORG_PRIVACY_POLICY_URL=$TM_ORG_PRIVACY_POLICY_URL
-REACT_APP_ORG_TWITTER=$TM_ORG_TWITTER
-REACT_APP_ORG_FB=$TM_ORG_FB
-REACT_APP_ORG_INSTAGRAM=$TM_ORG_INSTAGRAM
-REACT_APP_ORG_YOUTUBE=$TM_ORG_YOUTUBE
-REACT_APP_ORG_GITHUB=$TM_ORG_GITHUB
-REACT_APP_DEFAULT_LOCALE=$TM_DEFAULT_LOCALE
-REACT_APP_TM_MAPPER_LEVEL_INTERMEDIATE=$TM_MAPPER_LEVEL_INTERMEDIATE
-REACT_APP_TM_MAPPER_LEVEL_ADVANCED=$TM_MAPPER_LEVEL_ADVANCED
-REACT_APP_MATOMO_ID=$TM_MATOMO_ID
-REACT_APP_MATOMO_ENDPOINT=$TM_MATOMO_ENDPOINT
-REACT_APP_SERVICE_DESK=$TM_SERVICE_DESK
-REACT_APP_IMAGE_UPLOAD_API_URL=$TM_IMAGE_UPLOAD_API_URL
-REACT_APP_HOMEPAGE_VIDEO_URL=$TM_HOMEPAGE_VIDEO_URL
-REACT_APP_HOMEPAGE_IMG_HIGH=$TM_HOMEPAGE_IMG_HIGH
-REACT_APP_HOMEPAGE_IMG_LOW=$TM_HOMEPAGE_IMG_LOW
-REACT_APP_MAPBOX_TOKEN=$TM_MAPBOX_TOKEN
-REACT_APP_ENABLE_SERVICEWORKER=$TM_ENABLE_SERVICEWORKER
-REACT_APP_MAX_FILESIZE=$TM_IMPORT_MAX_FILESIZE
-REACT_APP_MAX_AOI_AREA=$TM_MAX_AOI_AREA
-REACT_APP_OHSOME_STATS_BASE_URL=$OHSOME_STATS_BASE_URL
-REACT_APP_OHSOME_STATS_TOKEN=$OHSOME_STATS_TOKEN
-REACT_APP_OSM_CLIENT_ID=$TM_CLIENT_ID
-REACT_APP_OSM_CLIENT_SECRET=$TM_CLIENT_SECRET
-REACT_APP_OSM_REDIRECT_URI=$TM_REDIRECT_URI
-REACT_APP_OSM_SERVER_URL=$OSM_SERVER_URL
-REACT_APP_OSM_SERVER_API_URL=$OSM_SERVER_API_URL
-REACT_APP_TM_ORG_NAME=$TM_ORG_NAME
-REACT_APP_OSM_REGISTER_URL=$OSM_REGISTER_URL
-REACT_APP_ID_EDITOR_URL=$ID_EDITOR_URL
-REACT_APP_POTLATCH2_EDITOR_URL=$POTLATCH2_EDITOR_URL
-REACT_APP_SENTRY_FRONTEND_DSN=$TM_SENTRY_FRONTEND_DSN
-REACT_APP_ENVIRONMENT=$TM_ENVIRONMENT
-REACT_APP_TM_DEFAULT_CHANGESET_COMMENT=$TM_DEFAULT_CHANGESET_COMMENT
-REACT_APP_RAPID_EDITOR_URL=$RAPID_EDITOR_URL
-REACT_APP_EXPORT_TOOL_S3_URL=$EXPORT_TOOL_S3_URL
-REACT_APP_ENABLE_EXPORT_TOOL=$ENABLE_EXPORT_TOOL
+VITE_BASE_URL=$TM_APP_BASE_URL
+VITE_API_URL=$TM_APP_API_URL
+VITE_API_VERSION=$TM_APP_API_VERSION
+VITE_ORG_NAME=$TM_ORG_NAME
+VITE_ORG_CODE=$TM_ORG_CODE
+VITE_ORG_URL=$TM_ORG_URL
+VITE_ORG_LOGO=$TM_ORG_LOGO
+VITE_ORG_PRIVACY_POLICY_URL=$TM_ORG_PRIVACY_POLICY_URL
+VITE_ORG_TWITTER=$TM_ORG_TWITTER
+VITE_ORG_FB=$TM_ORG_FB
+VITE_ORG_INSTAGRAM=$TM_ORG_INSTAGRAM
+VITE_ORG_YOUTUBE=$TM_ORG_YOUTUBE
+VITE_ORG_GITHUB=$TM_ORG_GITHUB
+VITE_DEFAULT_LOCALE=$TM_DEFAULT_LOCALE
+VITE_TM_MAPPER_LEVEL_INTERMEDIATE=$TM_MAPPER_LEVEL_INTERMEDIATE
+VITE_TM_MAPPER_LEVEL_ADVANCED=$TM_MAPPER_LEVEL_ADVANCED
+VITE_MATOMO_ID=$TM_MATOMO_ID
+VITE_MATOMO_ENDPOINT=$TM_MATOMO_ENDPOINT
+VITE_SERVICE_DESK=$TM_SERVICE_DESK
+VITE_IMAGE_UPLOAD_API_URL=$TM_IMAGE_UPLOAD_API_URL
+VITE_HOMEPAGE_VIDEO_URL=$TM_HOMEPAGE_VIDEO_URL
+VITE_HOMEPAGE_IMG_HIGH=$TM_HOMEPAGE_IMG_HIGH
+VITE_HOMEPAGE_IMG_LOW=$TM_HOMEPAGE_IMG_LOW
+VITE_MAPBOX_TOKEN=$TM_MAPBOX_TOKEN
+VITE_ENABLE_SERVICEWORKER=$TM_ENABLE_SERVICEWORKER
+VITE_MAX_FILESIZE=$TM_IMPORT_MAX_FILESIZE
+VITE_MAX_AOI_AREA=$TM_MAX_AOI_AREA
+VITE_OHSOME_STATS_BASE_URL=$OHSOME_STATS_BASE_URL
+VITE_OHSOME_STATS_TOKEN=$OHSOME_STATS_TOKEN
+VITE_OSM_CLIENT_ID=$TM_CLIENT_ID
+VITE_OSM_CLIENT_SECRET=$TM_CLIENT_SECRET
+VITE_OSM_REDIRECT_URI=$TM_REDIRECT_URI
+VITE_OSM_SERVER_URL=$OSM_SERVER_URL
+VITE_OSM_SERVER_API_URL=$OSM_SERVER_API_URL
+VITE_TM_ORG_NAME=$TM_ORG_NAME
+VITE_OSM_REGISTER_URL=$OSM_REGISTER_URL
+VITE_ID_EDITOR_URL=$ID_EDITOR_URL
+VITE_POTLATCH2_EDITOR_URL=$POTLATCH2_EDITOR_URL
+VITE_SENTRY_FRONTEND_DSN=$TM_SENTRY_FRONTEND_DSN
+VITE_ENVIRONMENT=$TM_ENVIRONMENT
+VITE_TM_DEFAULT_CHANGESET_COMMENT=$TM_DEFAULT_CHANGESET_COMMENT
+VITE_RAPID_EDITOR_URL=$RAPID_EDITOR_URL
+VITE_EXPORT_TOOL_S3_URL=$EXPORT_TOOL_S3_URL
+VITE_ENABLE_EXPORT_TOOL=$ENABLE_EXPORT_TOOL
diff --git a/frontend/public/index.html b/frontend/index.html
similarity index 81%
rename from frontend/public/index.html
rename to frontend/index.html
index 8d5db278e6..0d95f28e0a 100644
--- a/frontend/public/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
.env; else cp .env.expand .env; fi\"",
- "start": "npm run preparation && npm run copy-static && npm run copy-id-static && npm run patch-rapid && craco start",
- "build": "npm run preparation && npm run update-static && npm run update-id-static && npm run patch-rapid && craco build && npm run sentry:sourcemaps", "prettier": "prettier --write 'src/**/*.js'",
+ "dev": "vite",
+ "start": "npm run preparation && npm run copy-static && npm run copy-id-static && npm run patch-rapid && vite start",
+ "build": "npm run preparation && npm run update-static && npm run update-id-static && npm run patch-rapid && vite build && npm run sentry:sourcemaps",
+ "prettier": "prettier --write 'src/**/*.js'",
"lint": "eslint src",
- "test": "npm run lint && craco test --env=jsdom",
+ "test": "npm run lint && vitest",
+ "test:ui": "npm run lint && vitest --ui",
"coverage": "npm run test -- --coverage --watchAll=false",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"sentry:sourcemaps": "if sentry-cli info; then sentry-cli sourcemaps inject --org humanitarian-openstreetmap-tea --project taskingmanager-frontend ./build && sentry-cli sourcemaps upload --org humanitarian-openstreetmap-tea --project taskingmanager-frontend ./build; fi"
@@ -112,20 +117,33 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
- "@craco/craco": "^7.1.0",
"@sentry/cli": "^2.28.6",
"@tanstack/eslint-plugin-query": "^4.29.8",
- "@testing-library/jest-dom": "^5.16.5",
- "@testing-library/react": "^14.2.1",
- "@testing-library/user-event": "^14.4.3",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/chart.js": "^2.9.41",
+ "@types/dompurify": "^3.0.5",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@types/react-redux": "^7.1.34",
+ "@types/react-router-dom": "^5.3.3",
+ "@types/react-test-renderer": "^18.3.0",
+ "@types/slug": "^5.0.9",
+ "@vitejs/plugin-react-swc": "^3.7.0",
+ "@vitest/ui": "2.1.8",
"combine-react-intl-messages": "^4.0.0",
"jest-canvas-mock": "^2.5.2",
- "msw": "^1.3.2",
+ "msw": "^2.7.0",
"prettier": "^2.8.8",
"react-scripts": "^5.0.1",
"react-select-event": "^5.5.1",
"react-test-renderer": "^18.2.0",
- "source-map-explorer": "^2.5.3"
+ "source-map-explorer": "^2.5.3",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.2",
+ "vitest": "^2.1.8"
},
"resolutions": {
"dom-accessibility-api": "0.5.14"
@@ -142,6 +160,7 @@
]
},
"volta": {
- "node": "18.19.1"
+ "node": "18.19.1",
+ "yarn": "1.22.19"
}
}
diff --git a/frontend/src/App.js b/frontend/src/App.js
deleted file mode 100644
index b79667f555..0000000000
--- a/frontend/src/App.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Suspense, useEffect } from 'react';
-import { RouterProvider } from 'react-router-dom';
-import { Toaster } from 'react-hot-toast';
-import ReactPlaceholder from 'react-placeholder';
-import { useMeta } from 'react-meta-elements';
-import { useSelector } from 'react-redux';
-import { ErrorBoundary } from '@sentry/react';
-import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
-import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
-
-import './assets/styles/index.scss';
-
-import { getUserDetails } from './store/actions/auth';
-import { store } from './store';
-import { ORG_NAME, MATOMO_ID } from './config';
-import { Preloader } from './components/preloader';
-import { FallbackComponent } from './views/fallback';
-import { Banner, ArchivalNotificationBanner } from './components/banner/index';
-import { router } from './routes';
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- refetchOnWindowFocus: false,
- retry: (failureCount, error) => {
- // Don't retry for 401 or 403 errors
- const maxRetries = 3;
- if (error?.response?.status) {
- const statusCode = error.response.status;
- if (statusCode === 401 || statusCode === 403) {
- return false;
- }
- }
- return failureCount < maxRetries;
- },
- },
- },
-});
-
-const App = () => {
- useMeta({ property: 'og:url', content: process.env.REACT_APP_BASE_URL });
- useMeta({ name: 'author', content: ORG_NAME });
- const isLoading = useSelector((state) => state.loader.isLoading);
- const locale = useSelector((state) => state.preferences.locale);
-
- useEffect(() => {
- // fetch user details endpoint when the user is returning to a logged in session
- store.dispatch(getUserDetails(store.getState()));
- }, []);
-
- return (
-
}>
- {isLoading ? (
-
- ) : (
-
-
-
- }>
- } />
-
-
-
-
-
- {MATOMO_ID &&
}
-
-
- )}
-
- );
-};
-
-export default App;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000000..1f949fea5a
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,84 @@
+import { Suspense, useEffect } from 'react';
+import { RouterProvider } from 'react-router-dom';
+import { Toaster } from 'react-hot-toast';
+import ReactPlaceholder from 'react-placeholder';
+import { useMeta } from 'react-meta-elements';
+import { useSelector } from 'react-redux';
+import { ErrorBoundary } from '@sentry/react';
+import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+
+import './assets/styles/index.scss';
+
+import { getUserDetails } from './store/actions/auth';
+import { RootStore, store } from './store';
+import { ORG_NAME, MATOMO_ID } from './config';
+import { Preloader } from './components/preloader';
+import { FallbackComponent } from './views/fallback';
+import { Banner, ArchivalNotificationBanner } from './components/banner/index';
+import { router } from './routes';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: (failureCount, error) => {
+ // Don't retry for 401 or 403 errors
+ const maxRetries = 3;
+ if (error?.response?.status) {
+ const statusCode = error.response.status;
+ if (statusCode === 401 || statusCode === 403) {
+ return false;
+ }
+ }
+ return failureCount < maxRetries;
+ },
+ },
+ },
+});
+
+const App = () => {
+ useMeta({ property: 'og:url', content: import.meta.env.VITE_BASE_URL });
+ useMeta({ name: 'author', content: ORG_NAME });
+ const isLoading = useSelector((state: RootStore) => state.loader.isLoading);
+ const locale = useSelector((state: RootStore) => state.preferences.locale);
+
+ useEffect(() => {
+ // fetch user details endpoint when the user is returning to a logged in session
+ store.dispatch(getUserDetails(store.getState()));
+ }, []);
+
+ return (
+
}>
+ {isLoading ? (
+
+ ) : (
+
+
+
+ }>
+ } />
+
+
+
+
+
+ {MATOMO_ID &&
}
+
+
+ )}
+
+ );
+};
+
+export default App;
diff --git a/frontend/src/api/apiClient.js b/frontend/src/api/apiClient.js
deleted file mode 100644
index ebc860d0f9..0000000000
--- a/frontend/src/api/apiClient.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import axios from 'axios';
-
-import { API_URL } from '../config';
-
-const api = (token, locale) => {
- const instance = axios.create({
- baseURL: API_URL.toString(),
- headers: {
- 'Content-Type': 'application/json',
- ...(token && { Authorization: `Token ${token}` }),
- ...(locale && { 'Accept-Language': locale.replace('-', '_') || 'en' }),
- },
- });
- return instance;
-};
-
-export default api;
diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts
new file mode 100644
index 0000000000..f51b172c22
--- /dev/null
+++ b/frontend/src/api/apiClient.ts
@@ -0,0 +1,17 @@
+import axios from 'axios';
+
+import { API_URL } from '../config';
+
+const api = (token?: string, locale?: string) => {
+ const instance = axios.create({
+ baseURL: API_URL.toString(),
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token && { Authorization: `Token ${token}` }),
+ ...(locale && { 'Accept-Language': locale.replace('-', '_') || 'en' }),
+ },
+ });
+ return instance;
+};
+
+export default api;
diff --git a/frontend/src/api/notifications.js b/frontend/src/api/notifications.js
deleted file mode 100644
index 358e66dfc4..0000000000
--- a/frontend/src/api/notifications.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useSelector } from 'react-redux';
-import { useQuery } from '@tanstack/react-query';
-
-import { backendToQueryConversion } from '../hooks/UseInboxQueryAPI';
-import { remapParamsToAPI } from '../utils/remapParamsToAPI';
-import api from './apiClient';
-
-export const useNotificationsQuery = (inboxQuery) => {
- const token = useSelector((state) => state.auth.token);
- const fetchNotifications = async (signal, queryKey) => {
- const [, inboxQuery] = queryKey;
- const response = await api(token).get(`notifications/?${serializeParams(inboxQuery)}`, {
- signal,
- });
- return response.data;
- };
-
- return useQuery({
- queryKey: ['notifications', inboxQuery],
- queryFn: ({ signal, queryKey }) => fetchNotifications(signal, queryKey),
- keepPreviousData: true,
- placeholderData: {},
- });
-};
-
-export const useUnreadNotificationsCountQuery = () => {
- const token = useSelector((state) => state.auth.token);
- const fetchUnreadNotificationCount = async (signal) => {
- const response = await api(token).get('notifications/queries/own/count-unread/', {
- signal,
- });
- return response.data;
- };
-
- return useQuery({
- queryKey: ['notifications', 'unread-count'],
- queryFn: ({ signal }) => fetchUnreadNotificationCount(signal),
- refetchInterval: 1000 * 30,
- refetchOnWindowFocus: true,
- });
-};
-
-function serializeParams(queryState) {
- const obj = remapParamsToAPI(queryState, backendToQueryConversion);
-
- Object.keys(obj).forEach((key) => {
- if (obj[key] === undefined) {
- delete obj[key];
- }
- });
-
- return Object.entries(obj)
- .map(([key, val]) => `${key}=${val}`)
- .join('&');
-}
diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts
new file mode 100644
index 0000000000..f2313dc0c7
--- /dev/null
+++ b/frontend/src/api/notifications.ts
@@ -0,0 +1,58 @@
+import { useSelector } from 'react-redux';
+import { useQuery } from '@tanstack/react-query';
+
+import { backendToQueryConversion } from '../hooks/UseInboxQueryAPI';
+import { remapParamsToAPI } from '../utils/remapParamsToAPI';
+import api from './apiClient';
+import { RootStore } from '../store';
+
+export const useNotificationsQuery = (inboxQuery: string) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const fetchNotifications = async (signal: AbortSignal, queryKey: string) => {
+ const [, inboxQuery] = queryKey;
+ const response = await api(token).get(`notifications/?${serializeParams(inboxQuery)}`, {
+ signal,
+ });
+ return response.data;
+ };
+
+ return useQuery({
+ queryKey: ['notifications', inboxQuery],
+ queryFn: ({ signal, queryKey }) => fetchNotifications(signal, queryKey),
+ keepPreviousData: true,
+ placeholderData: {},
+ });
+};
+
+export const useUnreadNotificationsCountQuery = () => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const fetchUnreadNotificationCount = async (signal: AbortSignal) => {
+ const response = await api(token).get('notifications/queries/own/count-unread/', {
+ signal,
+ });
+ return response.data;
+ };
+
+ return useQuery({
+ queryKey: ['notifications', 'unread-count'],
+ queryFn: ({ signal }: {
+ signal: AbortSignal
+ }) => fetchUnreadNotificationCount(signal),
+ refetchInterval: 1000 * 30,
+ refetchOnWindowFocus: true,
+ });
+};
+
+function serializeParams(queryState: unknown) {
+ const obj = remapParamsToAPI(queryState, backendToQueryConversion);
+
+ Object.keys(obj).forEach((key) => {
+ if (obj[key] === undefined) {
+ delete obj[key];
+ }
+ });
+
+ return Object.entries(obj)
+ .map(([key, val]) => `${key}=${val}`)
+ .join('&');
+}
diff --git a/frontend/src/api/organisations.js b/frontend/src/api/organisations.js
deleted file mode 100644
index ee539fc308..0000000000
--- a/frontend/src/api/organisations.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useSelector } from 'react-redux';
-
-import api from './apiClient';
-
-export const useUserOrganisationsQuery = (userId) => {
- const token = useSelector((state) => state.auth.token);
-
- const fetchOrganisations = ({ signal }) => {
- return api(token).get(`organisations/`, {
- signal,
- params: {
- manager_user_id: userId,
- omitManagerList: true,
- },
- });
- };
-
- return useQuery({
- queryKey: ['user-organisations', userId],
- queryFn: fetchOrganisations,
- select: (data) => data.data,
- });
-};
diff --git a/frontend/src/api/organisations.ts b/frontend/src/api/organisations.ts
new file mode 100644
index 0000000000..6a9d383b0f
--- /dev/null
+++ b/frontend/src/api/organisations.ts
@@ -0,0 +1,27 @@
+import { useQuery } from '@tanstack/react-query';
+import { useSelector } from 'react-redux';
+
+import api from './apiClient';
+import { RootStore } from '../store';
+
+export const useUserOrganisationsQuery = (userId: string | number) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+
+ const fetchOrganisations = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api(token).get(`organisations/`, {
+ signal,
+ params: {
+ manager_user_id: userId,
+ omitManagerList: true,
+ },
+ });
+ };
+
+ return useQuery({
+ queryKey: ['user-organisations', userId],
+ queryFn: fetchOrganisations,
+ select: (data) => data.data,
+ });
+};
diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js
deleted file mode 100644
index fc8083477a..0000000000
--- a/frontend/src/api/projects.js
+++ /dev/null
@@ -1,261 +0,0 @@
-import axios from 'axios';
-import { subMonths, format } from 'date-fns';
-import { useQuery } from '@tanstack/react-query';
-import { useSelector } from 'react-redux';
-
-import { remapParamsToAPI } from '../utils/remapParamsToAPI';
-import api from './apiClient';
-import { UNDERPASS_URL } from '../config';
-
-export const useProjectsQuery = (fullProjectsQuery, action, queryOptions) => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
-
- const fetchProjects = (signal, queryKey) => {
- const [, fullProjectsQuery, action] = queryKey;
- const paramsRemapped = remapParamsToAPI(fullProjectsQuery, backendToQueryConversion);
- // it's needed in order to query by action when the user goes to /explore page
- if (paramsRemapped.action === undefined && action) {
- paramsRemapped.action = action;
- }
-
- if (paramsRemapped.lastUpdatedTo) {
- paramsRemapped.lastUpdatedTo = format(subMonths(Date.now(), 6), 'yyyy-MM-dd');
- }
-
- return api(token, locale)
- .get('projects/', {
- signal,
- params: paramsRemapped,
- })
- .then((res) => res.data);
- };
-
- return useQuery({
- queryKey: ['projects', fullProjectsQuery, action],
- queryFn: ({ signal, queryKey }) => fetchProjects(signal, queryKey),
- keepPreviousData: true,
- ...queryOptions,
- });
-};
-
-export const useProjectQuery = (projectId, otherOptions) => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
- const fetchProject = ({ signal }) => {
- return api(token, locale).get(`projects/${projectId}/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project', projectId],
- queryFn: fetchProject,
- ...otherOptions,
- });
-};
-export const useProjectSummaryQuery = (projectId, otherOptions = {}) => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
-
- const fetchProjectSummary = ({ signal }) => {
- return api(token, locale).get(`projects/${projectId}/queries/summary/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-summary', projectId],
- queryFn: fetchProjectSummary,
- select: (data) => data.data,
- ...otherOptions,
- });
-};
-
-export const useProjectContributionsQuery = (projectId, otherOptions = {}) => {
- const fetchProjectContributions = ({ signal }) => {
- return api().get(`projects/${projectId}/contributions/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-contributions', projectId],
- queryFn: fetchProjectContributions,
- select: (data) => data.data.userContributions,
- ...otherOptions,
- });
-};
-
-export const useActivitiesQuery = (projectId) => {
- const ACTIVITIES_REFETCH_INTERVAL = 1000 * 60;
- const fetchProjectActivities = ({ signal }) => {
- return api().get(`projects/${projectId}/activities/latest/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-activities', projectId],
- queryFn: fetchProjectActivities,
- select: (data) => data.data,
- refetchIntervalInBackground: false,
- refetchInterval: ACTIVITIES_REFETCH_INTERVAL,
- refetchOnWindowFocus: true,
- useErrorBoundary: true,
- });
-};
-
-export const useTasksQuery = (projectId, otherOptions = {}) => {
- const fetchProjectTasks = ({ signal }) => {
- return api().get(`projects/${projectId}/tasks/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-tasks', projectId],
- queryFn: fetchProjectTasks,
- select: (data) => data.data,
- ...otherOptions,
- });
-};
-
-export const usePriorityAreasQuery = (projectId) => {
- const fetchProjectPriorityArea = (signal) => {
- return api().get(`projects/${projectId}/queries/priority-areas/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-priority-area', projectId],
- queryFn: ({ signal }) => fetchProjectPriorityArea(signal),
- select: (data) => data.data,
- });
-};
-
-export const useProjectTimelineQuery = (projectId) => {
- const fetchTimelineData = (signal) => {
- return api().get(`projects/${projectId}/contributions/queries/day/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-timeline', projectId],
- queryFn: ({ signal }) => fetchTimelineData(signal),
- select: (data) => data.data.stats,
- });
-};
-
-export const useTaskDetail = (projectId, taskId, shouldRefetch) => {
- const token = useSelector((state) => state.auth.token);
-
- const fetchTaskDetail = ({ signal }) => {
- return api(token).get(`projects/${projectId}/tasks/${taskId}/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['task-detail', projectId, taskId],
- queryFn: fetchTaskDetail,
- select: (data) => data.data,
- enabled: !!(projectId && taskId),
- refetchInterval: shouldRefetch ? 1000 * 60 : false,
- });
-};
-
-// MAPPING
-export const stopMapping = (projectId, taskId, comment, token, locale = 'en') => {
- return api(token, locale).post(`projects/${projectId}/tasks/actions/stop-mapping/${taskId}/`, {
- comment,
- });
-};
-
-export const splitTask = (projectId, taskId, token, locale) => {
- return api(token, locale).post(`projects/${projectId}/tasks/actions/split/${taskId}/`);
-};
-
-export const submitMappingTask = (url, payload, token, locale) => {
- return api(token, locale).post(url, payload);
-};
-
-// VALIDATION
-export const stopValidation = (projectId, payload, token, locale = 'en') => {
- return api(token, locale).post(`projects/${projectId}/tasks/actions/stop-validation/`, payload);
-};
-
-export const submitValidationTask = (projectId, payload, token, locale) => {
- return api(token, locale).post(
- `projects/${projectId}/tasks/actions/unlock-after-validation/`,
- payload,
- );
-};
-
-export const downloadAsCSV = (allQueryParams, action, token) => {
- const paramsRemapped = remapParamsToAPI(allQueryParams, backendToQueryConversion);
- // it's needed in order to query by action
- if (paramsRemapped.action === undefined && action) {
- paramsRemapped.action = action;
- }
-
- if (paramsRemapped.lastUpdatedTo) {
- paramsRemapped.lastUpdatedTo = format(subMonths(Date.now(), 6), 'yyyy-MM-dd');
- }
- return api(token).get('projects/', {
- params: paramsRemapped,
- });
-};
-
-export const useAvailableCountriesQuery = () => {
- const fetchGeojsonData = () => {
- return axios.get(`${UNDERPASS_URL}/availability`);
- };
-
- return useQuery({
- queryKey: ['priority-geojson'],
- queryFn: fetchGeojsonData,
- select: (res) => res.data,
- });
-};
-
-export const useAllPartnersQuery = (token, userId) => {
- const fetchAllPartners = () => {
- return api(token).get('partners/');
- };
-
- return useQuery({
- queryKey: ['all-partners', userId],
- queryFn: fetchAllPartners,
- select: (response) => response.data,
- });
-};
-
-const backendToQueryConversion = {
- difficulty: 'difficulty',
- campaign: 'campaign',
- team: 'teamId',
- organisation: 'organisationName',
- location: 'country',
- types: 'mappingTypes',
- exactTypes: 'mappingTypesExact',
- interests: 'interests',
- text: 'textSearch',
- page: 'page',
- orderBy: 'orderBy',
- orderByType: 'orderByType',
- createdByMe: 'createdByMe',
- managedByMe: 'managedByMe',
- favoritedByMe: 'favoritedByMe',
- mappedByMe: 'mappedByMe',
- status: 'projectStatuses',
- action: 'action',
- stale: 'lastUpdatedTo',
- createdFrom: 'createdFrom',
- basedOnMyInterests: 'basedOnMyInterests',
- partnerId: 'partnerId',
- partnershipFrom: 'partnershipFrom',
- partnershipTo: 'partnershipTo',
-};
diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts
new file mode 100644
index 0000000000..d8e6d7b3bd
--- /dev/null
+++ b/frontend/src/api/projects.ts
@@ -0,0 +1,295 @@
+import axios from 'axios';
+import { subMonths, format } from 'date-fns';
+import { QueryKey, QueryOptions, useQuery, UseQueryOptions } from '@tanstack/react-query';
+import { useSelector } from 'react-redux';
+
+import { remapParamsToAPI } from '../utils/remapParamsToAPI';
+import api from './apiClient';
+import { API_URL, UNDERPASS_URL } from '../config';
+import { RootStore } from '../store';
+
+export const useProjectsQuery = (
+ fullProjectsQuery: string,
+ action: string,
+ queryOptions: QueryOptions,
+) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const locale = useSelector((state: RootStore) => state.preferences['locale']);
+
+ const fetchProjects = async (signal: AbortSignal | undefined, queryKey: QueryKey) => {
+ const [, fullProjectsQuery, action] = queryKey;
+ const paramsRemapped = remapParamsToAPI(fullProjectsQuery, backendToQueryConversion);
+ // it's needed in order to query by action when the user goes to /explore page
+ if (paramsRemapped.action === undefined && action) {
+ paramsRemapped.action = action;
+ }
+
+ if (paramsRemapped.lastUpdatedTo) {
+ paramsRemapped.lastUpdatedTo = format(subMonths(Date.now(), 6), 'yyyy-MM-dd');
+ }
+
+ return await api(token, locale)
+ .get('projects/', {
+ signal,
+ params: paramsRemapped,
+ })
+ .then((res) => res.data);
+ };
+
+ return useQuery({
+ queryKey: ['projects', fullProjectsQuery, action],
+ queryFn: ({ signal, queryKey }) => fetchProjects(signal, queryKey),
+ keepPreviousData: true,
+ ...queryOptions,
+ });
+};
+
+export const useProjectQuery = (projectId: string, otherOptions: any) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const locale = useSelector((state: RootStore) => state.preferences['locale']);
+ const fetchProject = ({ signal }: { signal: AbortSignal }) => {
+ return api(token, locale).get(`projects/${projectId}/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project', projectId],
+ queryFn: fetchProject,
+ ...otherOptions,
+ });
+};
+
+type ProjectSummaryQueryOptions = Omit
, ('queryKey' | 'queryFn' | 'select')>;
+
+export const useProjectSummaryQuery = (projectId: string, otherOptions?: ProjectSummaryQueryOptions) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const locale = useSelector((state: RootStore) => state.preferences['locale']);
+
+ const fetchProjectSummary = ({ signal }: { signal: AbortSignal }) => {
+ return api(token, locale).get(`projects/${projectId}/queries/summary/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-summary', projectId],
+ queryFn: fetchProjectSummary,
+ select: (data: any) => data.data,
+ ...otherOptions,
+ });
+};
+
+export const useProjectContributionsQuery = (projectId: string, otherOptions = {}) => {
+ const fetchProjectContributions = ({ signal }: { signal: AbortSignal }) => {
+ return api().get(`projects/${projectId}/contributions/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-contributions', projectId],
+ queryFn: fetchProjectContributions,
+ select: (data) => data.data.userContributions,
+ ...otherOptions,
+ });
+};
+
+export const useActivitiesQuery = (projectId: string) => {
+ const ACTIVITIES_REFETCH_INTERVAL = 1000 * 60;
+ const fetchProjectActivities = ({ signal }: { signal: AbortSignal }) => {
+ return api().get(`projects/${projectId}/activities/latest/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-activities', projectId],
+ queryFn: fetchProjectActivities,
+ select: (data) => data.data,
+ refetchIntervalInBackground: false,
+ refetchInterval: ACTIVITIES_REFETCH_INTERVAL,
+ refetchOnWindowFocus: true,
+ throwOnError: true,
+ });
+};
+
+export const useTasksQuery = (projectId: string, otherOptions = {}) => {
+ const fetchProjectTasks = ({ signal }: { signal: AbortSignal }) => {
+ return api().get(`projects/${projectId}/tasks/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-tasks', projectId],
+ queryFn: fetchProjectTasks,
+ select: (data) => data.data,
+ ...otherOptions,
+ });
+};
+
+export const usePriorityAreasQuery = (projectId: string) => {
+ const fetchProjectPriorityArea = (signal: { signal: AbortSignal }) => {
+ return api().get(`projects/${projectId}/queries/priority-areas/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-priority-area', projectId],
+ queryFn: ({ signal }) => fetchProjectPriorityArea(signal),
+ select: (data) => data.data,
+ });
+};
+
+export const useProjectTimelineQuery = (projectId: string) => {
+ const fetchTimelineData = (signal: { signal: AbortSignal }) => {
+ return api().get(`projects/${projectId}/contributions/queries/day/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-timeline', projectId],
+ queryFn: ({ signal }) => fetchTimelineData(signal),
+ select: (data) => data.data.stats,
+ });
+};
+
+export const useTaskDetail = (projectId: string, taskId: number, shouldRefetch: boolean) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+
+ const fetchTaskDetail = ({ signal }: { signal: AbortSignal }) => {
+ return api(token).get(`projects/${projectId}/tasks/${taskId}/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['task-detail', projectId, taskId],
+ queryFn: fetchTaskDetail,
+ select: (data) => data.data,
+ enabled: !!(projectId && taskId),
+ refetchInterval: shouldRefetch ? 1000 * 60 : false,
+ });
+};
+
+// MAPPING
+export const stopMapping = (
+ projectId: string,
+ taskId: number,
+ comment: string,
+ token: string,
+ locale: string = 'en',
+) => {
+ return api(token, locale).post(`projects/${projectId}/tasks/actions/stop-mapping/${taskId}/`, {
+ comment,
+ });
+};
+
+export const splitTask = (
+ projectId: string,
+ taskId: number,
+ token: string,
+ locale: string = 'en',
+) => {
+ return api(token, locale).post(`projects/${projectId}/tasks/actions/split/${taskId}/`);
+};
+
+export const submitMappingTask = (
+ url: string,
+ payload: any,
+ token: string,
+ locale: string = 'en',
+) => {
+ return api(token, locale).post(url, payload);
+};
+
+// VALIDATION
+export const stopValidation = (
+ projectId: string,
+ payload: any,
+ token: string,
+ locale: string = 'en',
+) => {
+ return api(token, locale).post(`projects/${projectId}/tasks/actions/stop-validation/`, payload);
+};
+
+export const submitValidationTask = (
+ projectId: string,
+ payload: any,
+ token: string,
+ locale: string = 'en',
+) => {
+ return api(token, locale).post(
+ `projects/${projectId}/tasks/actions/unlock-after-validation/`,
+ payload,
+ );
+};
+
+export const downloadAsCSV = (allQueryParams, action, token) => {
+ const paramsRemapped = remapParamsToAPI(allQueryParams, backendToQueryConversion);
+ // it's needed in order to query by action
+ if (paramsRemapped.action === undefined && action) {
+ paramsRemapped.action = action;
+ }
+
+ if (paramsRemapped.lastUpdatedTo) {
+ paramsRemapped.lastUpdatedTo = format(subMonths(Date.now(), 6), 'yyyy-MM-dd');
+ }
+ return api(token).get('projects/', {
+ params: paramsRemapped,
+ });
+};
+
+export const useAvailableCountriesQuery = () => {
+ const fetchGeojsonData = () => {
+ return axios.get(`${UNDERPASS_URL}/availability`);
+ };
+
+ return useQuery({
+ queryKey: ['priority-geojson'],
+ queryFn: fetchGeojsonData,
+ select: (res) => res.data,
+ });
+};
+
+export const useAllPartnersQuery = (token: string, userId: string) => {
+ const fetchAllPartners = () => {
+ return api(token).get('partners/');
+ };
+
+ return useQuery({
+ queryKey: ['all-partners', userId],
+ queryFn: fetchAllPartners,
+ select: (response) => response.data,
+ });
+};
+
+const backendToQueryConversion = {
+ difficulty: 'difficulty',
+ campaign: 'campaign',
+ team: 'teamId',
+ organisation: 'organisationName',
+ location: 'country',
+ types: 'mappingTypes',
+ exactTypes: 'mappingTypesExact',
+ interests: 'interests',
+ text: 'textSearch',
+ page: 'page',
+ orderBy: 'orderBy',
+ orderByType: 'orderByType',
+ createdByMe: 'createdByMe',
+ managedByMe: 'managedByMe',
+ favoritedByMe: 'favoritedByMe',
+ mappedByMe: 'mappedByMe',
+ status: 'projectStatuses',
+ action: 'action',
+ stale: 'lastUpdatedTo',
+ createdFrom: 'createdFrom',
+ basedOnMyInterests: 'basedOnMyInterests',
+ partnerId: 'partnerId',
+ partnershipFrom: 'partnershipFrom',
+ partnershipTo: 'partnershipTo',
+};
diff --git a/frontend/src/api/questionsAndComments.js b/frontend/src/api/questionsAndComments.js
deleted file mode 100644
index 4c907e7a12..0000000000
--- a/frontend/src/api/questionsAndComments.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { useSelector } from 'react-redux';
-
-import api from './apiClient';
-
-export const useCommentsQuery = (projectId, page) => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
-
- const getComments = ({ signal }) => {
- return api(token, locale).get(`projects/${projectId}/comments/`, {
- signal,
- params: {
- perPage: 5,
- page,
- },
- });
- };
-
- return useQuery({
- queryKey: ['questions-and-comments', projectId, page],
- queryFn: getComments,
- select: (data) => data.data,
- });
-};
-
-export const postProjectComment = (projectId, comment, token, locale = 'en') => {
- return api(token, locale).post(`projects/${projectId}/comments/`, { message: comment });
-};
-
-export const postTaskComment = (projectId, taskId, comment, token, locale = 'en') => {
- return api(token, locale).post(`projects/${projectId}/comments/tasks/${taskId}/`, {
- comment,
- });
-};
diff --git a/frontend/src/api/questionsAndComments.ts b/frontend/src/api/questionsAndComments.ts
new file mode 100644
index 0000000000..c87b354456
--- /dev/null
+++ b/frontend/src/api/questionsAndComments.ts
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query';
+import { useSelector } from 'react-redux';
+
+import api from './apiClient';
+import { RootStore } from '../store';
+
+export const useCommentsQuery = (projectId: string, page: number) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const locale = useSelector((state: RootStore) => state.preferences['locale']);
+
+ const getComments = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api(token, locale).get(`projects/${projectId}/comments/`, {
+ signal,
+ params: {
+ perPage: 5,
+ page,
+ },
+ });
+ };
+
+ return useQuery({
+ queryKey: ['questions-and-comments', projectId, page],
+ queryFn: getComments,
+ select: (data) => data.data,
+ });
+};
+
+export const postProjectComment = (projectId: string, comment: string, token: string, locale: string = 'en') => {
+ return api(token, locale).post(`projects/${projectId}/comments/`, { message: comment });
+};
+
+export const postTaskComment = (projectId: string, taskId: number, comment: string, token: string, locale: string = 'en') => {
+ return api(token, locale).post(`projects/${projectId}/comments/tasks/${taskId}/`, {
+ comment,
+ });
+};
diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js
deleted file mode 100644
index 1016afac77..0000000000
--- a/frontend/src/api/stats.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-
-import { fetchExternalJSONAPI } from '../network/genericJSONRequest';
-import api from './apiClient';
-import { OHSOME_STATS_BASE_URL, defaultChangesetComment } from '../config';
-
-const ohsomeProxyAPI = (url) => {
- const token = localStorage.getItem('token');
- return api(token).get(`users/statistics/ohsome/?url=${url}`);
-};
-
-export const useSystemStatisticsQuery = () => {
- const fetchSystemStats = ({ signal }) => {
- return api().get(`system/statistics/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['tm-stats'],
- queryFn: fetchSystemStats,
- useErrorBoundary: true,
- });
-};
-
-export const useProjectStatisticsQuery = (projectId) => {
- const fetchProjectStats = ({ signal }) => {
- return api().get(`projects/${projectId}/statistics/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['project-stats'],
- queryFn: fetchProjectStats,
- select: (data) => data.data,
- });
-};
-
-export const useOsmStatsQuery = () => {
- const fetchOsmStats = ({ signal }) => {
- return api().get(`${OHSOME_STATS_BASE_URL}/stats/${defaultChangesetComment}-%2A`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['osm-stats'],
- queryFn: fetchOsmStats,
- useErrorBoundary: true,
- select: (data) => data.data.result,
- });
-};
-
-export const useOsmHashtagStatsQuery = (defaultComment) => {
- const fetchOsmStats = ({ signal }) => {
- return api().get(`${OHSOME_STATS_BASE_URL}/stats/${defaultComment[0].replace('#', '')}`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['osm-hashtag-stats'],
- queryFn: fetchOsmStats,
- useErrorBoundary: true,
- enabled: Boolean(defaultComment?.[0]),
- select: (data) => data.data.result,
- });
-};
-
-export const useUserOsmStatsQuery = (id) => {
- const fetchUserOsmStats = () => {
- return ohsomeProxyAPI(
- `${OHSOME_STATS_BASE_URL}/topic/poi,highway,building,waterway/user?userId=${id}`,
- );
- };
-
- return useQuery({
- queryKey: ['user-osm-stats'],
- queryFn: fetchUserOsmStats,
- // userDetail.test.js fails on CI when useErrorBoundary=true
- useErrorBoundary: process.env.NODE_ENV !== 'test',
- select: (data) => data.data.result,
- enabled: !!id,
- });
-};
-
-export const useOsmStatsMetadataQuery = () => {
- const fetchOsmStatsMetadata = () => {
- return fetchExternalJSONAPI(`${OHSOME_STATS_BASE_URL}/metadata`);
- };
-
- return useQuery({
- queryKey: ['osm-stats-metadata'],
- queryFn: fetchOsmStatsMetadata,
- useErrorBoundary: true,
- select: (data) => data.result,
- });
-};
diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts
new file mode 100644
index 0000000000..59c5701760
--- /dev/null
+++ b/frontend/src/api/stats.ts
@@ -0,0 +1,108 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { fetchExternalJSONAPI } from '../network/genericJSONRequest';
+import api from './apiClient';
+import { OHSOME_STATS_BASE_URL, defaultChangesetComment } from '../config';
+
+const ohsomeProxyAPI = (url: string) => {
+ const token = localStorage.getItem('token');
+ if (!token) return null;
+ return api(token).get(`users/statistics/ohsome/?url=${url}`);
+};
+
+export const useSystemStatisticsQuery = () => {
+ const fetchSystemStats = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api().get(`system/statistics/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['tm-stats'],
+ queryFn: fetchSystemStats,
+ throwOnError: true,
+ });
+};
+
+export const useProjectStatisticsQuery = (projectId: string) => {
+ const fetchProjectStats = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api().get(`projects/${projectId}/statistics/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['project-stats'],
+ queryFn: fetchProjectStats,
+ select: (data) => data.data,
+ });
+};
+
+export const useOsmStatsQuery = () => {
+ const fetchOsmStats = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api().get(`${OHSOME_STATS_BASE_URL}/stats/${defaultChangesetComment}-%2A`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['osm-stats'],
+ queryFn: fetchOsmStats,
+ throwOnError: true,
+ select: (data) => data.data.result,
+ });
+};
+
+export const useOsmHashtagStatsQuery = (defaultComment: string) => {
+ const fetchOsmStats = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api().get(`${OHSOME_STATS_BASE_URL}/stats/${defaultComment[0].replace('#', '')}`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['osm-hashtag-stats'],
+ queryFn: fetchOsmStats,
+ throwOnError: true,
+ enabled: Boolean(defaultComment?.[0]),
+ select: (data) => data.data.result,
+ });
+};
+
+export const useUserOsmStatsQuery = (id: string) => {
+ const fetchUserOsmStats = () => {
+ return ohsomeProxyAPI(
+ `${OHSOME_STATS_BASE_URL}/topic/poi,highway,building,waterway/user?userId=${id}`,
+ );
+ };
+
+ return useQuery({
+ queryKey: ['user-osm-stats'],
+ queryFn: fetchUserOsmStats,
+ // userDetail.test.js fails on CI when throwOnError=true
+ throwOnError: import.meta.env.NODE_ENV !== 'test',
+ select: (data) => data?.data.result,
+ enabled: !!id,
+ });
+};
+
+export const useOsmStatsMetadataQuery = () => {
+ const fetchOsmStatsMetadata = () => {
+ return fetchExternalJSONAPI(`${OHSOME_STATS_BASE_URL}/metadata`);
+ };
+
+ return useQuery({
+ queryKey: ['osm-stats-metadata'],
+ queryFn: fetchOsmStatsMetadata,
+ throwOnError: true,
+ select: (data) => data.result,
+ });
+};
diff --git a/frontend/src/api/teams.js b/frontend/src/api/teams.js
deleted file mode 100644
index c79ae9d76a..0000000000
--- a/frontend/src/api/teams.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useSelector } from 'react-redux';
-import { useQuery } from '@tanstack/react-query';
-
-import api from './apiClient';
-
-export const useTeamsQuery = (params, otherOptions) => {
- const token = useSelector((state) => state.auth.token);
-
- const fetchUserTeams = ({ signal }) => {
- return api(token).get(`teams/`, {
- signal,
- params: params,
- });
- };
-
- return useQuery({
- queryKey: ['user-teams', params],
- queryFn: fetchUserTeams,
- select: (data) => data.data,
- ...otherOptions,
- });
-};
diff --git a/frontend/src/api/teams.ts b/frontend/src/api/teams.ts
new file mode 100644
index 0000000000..e17ee39b7d
--- /dev/null
+++ b/frontend/src/api/teams.ts
@@ -0,0 +1,25 @@
+import { useSelector } from 'react-redux';
+import { useQuery } from '@tanstack/react-query';
+
+import api from './apiClient';
+import { RootStore } from '../store';
+
+export const useTeamsQuery = (params: any, otherOptions: any) => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+
+ const fetchUserTeams = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api(token).get(`teams/`, {
+ signal,
+ params: params,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['user-teams', params],
+ queryFn: fetchUserTeams,
+ select: (data) => data.data,
+ ...otherOptions,
+ });
+};
diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js
deleted file mode 100644
index 641baadfea..0000000000
--- a/frontend/src/api/user.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useSelector } from 'react-redux';
-import { useQuery } from '@tanstack/react-query';
-
-import api from './apiClient';
-
-export const useLockedTasksQuery = () => {
- const token = useSelector((state) => state.auth.token);
- const locale = useSelector((state) => state.preferences['locale']);
-
- const fetchLockedTasks = ({ signal }) => {
- return api(token, locale).get(`users/queries/tasks/locked/details/`, {
- signal,
- });
- };
-
- return useQuery({
- queryKey: ['locked-tasks'],
- queryFn: fetchLockedTasks,
- select: (data) => data.data?.tasks,
- cacheTime: 0,
- useErrorBoundary: true,
- });
-};
diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts
new file mode 100644
index 0000000000..0eb07c0c00
--- /dev/null
+++ b/frontend/src/api/user.ts
@@ -0,0 +1,26 @@
+import { useSelector } from 'react-redux';
+import { useQuery } from '@tanstack/react-query';
+
+import api from './apiClient';
+import { RootStore } from '../store';
+
+export const useLockedTasksQuery = () => {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const locale = useSelector((state: RootStore) => state.preferences['locale']);
+
+ const fetchLockedTasks = ({ signal }: {
+ signal: AbortSignal;
+ }) => {
+ return api(token, locale).get(`users/queries/tasks/locked/details/`, {
+ signal,
+ });
+ };
+
+ return useQuery({
+ queryKey: ['locked-tasks'],
+ queryFn: fetchLockedTasks,
+ select: (data) => data.data?.tasks,
+ gcTime: 0,
+ throwOnError: true,
+ });
+};
diff --git a/frontend/src/components/alert/index.js b/frontend/src/components/alert/index.js
deleted file mode 100644
index 7539c03746..0000000000
--- a/frontend/src/components/alert/index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import { BanIcon, CheckIcon, InfoIcon, AlertIcon } from '../svgIcons';
-import messages from '../../views/messages';
-
-export const Alert = ({
- type = 'info',
- compact = false,
- inline = false,
- iconClassName,
- children,
-}) => {
- const icons = {
- info: InfoIcon,
- success: CheckIcon,
- warning: AlertIcon,
- error: BanIcon,
- };
- const Icon = icons[type];
-
- const color = {
- info: 'b--blue bg-lightest-blue',
- success: 'b--dark-green bg-washed-green',
- warning: 'b--gold bg-washed-yellow',
- error: 'b--dark-red bg-washed-red',
- };
- const iconColor = {
- info: 'blue',
- success: 'dark-green',
- warning: 'gold',
- error: 'dark-red',
- };
-
- return (
-
-
- {children}
-
- );
-};
-
-export function EntityError({ entity, action = 'creation' }) {
- const messageType = action === 'updation' ? 'entityInfoUpdationFailure' : 'entityCreationFailure';
-
- return (
-
-
-
- );
-}
diff --git a/frontend/src/components/alert/index.tsx b/frontend/src/components/alert/index.tsx
new file mode 100644
index 0000000000..28facb7820
--- /dev/null
+++ b/frontend/src/components/alert/index.tsx
@@ -0,0 +1,67 @@
+import { FormattedMessage } from 'react-intl';
+
+import { BanIcon, CheckIcon, InfoIcon, AlertIcon } from '../svgIcons';
+import messages from '../../views/messages';
+
+export const Alert = ({
+ type = 'info',
+ compact = false,
+ inline = false,
+ iconClassName,
+ children,
+}: {
+ type: 'info' | 'success' | 'warning' | 'error';
+ compact?: boolean;
+ inline?: boolean;
+ iconClassName?: string;
+ children: React.ReactNode;
+}) => {
+ const icons = {
+ info: InfoIcon,
+ success: CheckIcon,
+ warning: AlertIcon,
+ error: BanIcon,
+ };
+ const Icon = icons[type];
+
+ const color = {
+ info: 'b--blue bg-lightest-blue',
+ success: 'b--dark-green bg-washed-green',
+ warning: 'b--gold bg-washed-yellow',
+ error: 'b--dark-red bg-washed-red',
+ };
+ const iconColor = {
+ info: 'blue',
+ success: 'dark-green',
+ warning: 'gold',
+ error: 'dark-red',
+ };
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+export function EntityError({ entity, action = 'creation' }: {
+ entity: string;
+ action?: 'creation' | 'updation';
+}) {
+ const messageType = action === 'updation' ? 'entityInfoUpdationFailure' : 'entityCreationFailure';
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/alert/tests/index.test.js b/frontend/src/components/alert/tests/index.test.js
deleted file mode 100644
index 3b28c1c35d..0000000000
--- a/frontend/src/components/alert/tests/index.test.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import { Alert } from '../index';
-
-describe('Alert Component', () => {
- it('with error type', () => {
- const { container } = render(An error message);
- expect(container.querySelector('svg')).toBeInTheDocument(); // ban icon
- expect(container.querySelector('.dark-red')).toBeInTheDocument();
- expect(container.querySelector('div').className).toBe(
- 'db blue-dark bl bw2 br2 pa3 b--dark-red bg-washed-red',
- );
- expect(screen.queryByText(/An error message/)).toBeInTheDocument();
- });
-
- it('with success type', () => {
- const { container } = render(
-
- Success message comes here
- ,
- );
- expect(container.querySelector('svg')).toBeInTheDocument();
- expect(container.querySelector('.dark-green')).toBeInTheDocument();
- const divClassName = container.querySelector('div').className;
- expect(divClassName).toContain('b--dark-green bg-washed-green');
- expect(divClassName).toContain('di');
- expect(divClassName).not.toContain('db');
- expect(screen.queryByText(/Success message comes here/)).toBeInTheDocument();
- });
-
- it('with info type', () => {
- const { container } = render(
-
- Information
- ,
- );
- expect(container.querySelector('svg')).toBeInTheDocument();
- expect(container.querySelector('.blue')).toBeInTheDocument();
- const divClassName = container.querySelector('div').className;
- expect(divClassName).toContain('b--blue bg-lightest-blue');
- expect(divClassName).toContain('db');
- expect(divClassName).not.toContain('di');
- expect(divClassName).toContain('pa2');
- expect(divClassName).not.toContain('pa3');
- expect(screen.queryByText(/Information/)).toBeInTheDocument();
- });
-
- it('with warning type', () => {
- const { container } = render(
-
- It's only a warning...
- ,
- );
- expect(container.querySelector('svg')).toBeInTheDocument();
- expect(container.querySelector('.gold')).toBeInTheDocument();
- const divClassName = container.querySelector('div').className;
- expect(divClassName).toContain('b--gold bg-washed-yellow');
- expect(divClassName).toContain('di');
- expect(divClassName).toContain('pa2');
- expect(screen.queryByText(/It's only a warning.../)).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/alert/tests/index.test.tsx b/frontend/src/components/alert/tests/index.test.tsx
new file mode 100644
index 0000000000..93f38b0c99
--- /dev/null
+++ b/frontend/src/components/alert/tests/index.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from '@testing-library/react';
+
+import { Alert } from '../index';
+
+describe('Alert Component', () => {
+ it('with error type', () => {
+ const { container } = render(An error message);
+ expect(container.querySelector('svg')).toBeInTheDocument(); // ban icon
+ expect(container.querySelector('.dark-red')).toBeInTheDocument();
+ expect(container.querySelector('div')?.className).toBe(
+ 'db blue-dark bl bw2 br2 pa3 b--dark-red bg-washed-red',
+ );
+ expect(screen.queryByText(/An error message/)).toBeInTheDocument();
+ });
+
+ it('with success type', () => {
+ const { container } = render(
+
+ Success message comes here
+ ,
+ );
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ expect(container.querySelector('.dark-green')).toBeInTheDocument();
+ const divClassName = container.querySelector('div')?.className;
+ expect(divClassName).toContain('b--dark-green bg-washed-green');
+ expect(divClassName).toContain('di');
+ expect(divClassName).not.toContain('db');
+ expect(screen.queryByText(/Success message comes here/)).toBeInTheDocument();
+ });
+
+ it('with info type', () => {
+ const { container } = render(
+
+ Information
+ ,
+ );
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ expect(container.querySelector('.blue')).toBeInTheDocument();
+ const divClassName = container.querySelector('div')?.className;
+ expect(divClassName).toContain('b--blue bg-lightest-blue');
+ expect(divClassName).toContain('db');
+ expect(divClassName).not.toContain('di');
+ expect(divClassName).toContain('pa2');
+ expect(divClassName).not.toContain('pa3');
+ expect(screen.queryByText(/Information/)).toBeInTheDocument();
+ });
+
+ it('with warning type', () => {
+ const { container } = render(
+
+ It's only a warning...
+ ,
+ );
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ expect(container.querySelector('.gold')).toBeInTheDocument();
+ const divClassName = container.querySelector('div')?.className;
+ expect(divClassName).toContain('b--gold bg-washed-yellow');
+ expect(divClassName).toContain('di');
+ expect(divClassName).toContain('pa2');
+ expect(screen.queryByText(/It's only a warning.../)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/banner/index.js b/frontend/src/components/banner/index.tsx
similarity index 100%
rename from frontend/src/components/banner/index.js
rename to frontend/src/components/banner/index.tsx
diff --git a/frontend/src/components/banner/messages.js b/frontend/src/components/banner/messages.ts
similarity index 100%
rename from frontend/src/components/banner/messages.js
rename to frontend/src/components/banner/messages.ts
diff --git a/frontend/src/components/banner/tests/topBanner.test.js b/frontend/src/components/banner/tests/topBanner.test.js
deleted file mode 100644
index f969c5db7d..0000000000
--- a/frontend/src/components/banner/tests/topBanner.test.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-import { banner } from '../../../network/tests/mockData/miscellaneous';
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { TopBanner } from '../topBanner';
-
-it('should render the banner text ', async () => {
- renderWithRouter(
-
-
- ,
- );
- expect(await screen.findByText(banner.message)).toBeInTheDocument();
-});
diff --git a/frontend/src/components/banner/tests/topBanner.test.tsx b/frontend/src/components/banner/tests/topBanner.test.tsx
new file mode 100644
index 0000000000..60b654066b
--- /dev/null
+++ b/frontend/src/components/banner/tests/topBanner.test.tsx
@@ -0,0 +1,14 @@
+
+import { screen } from '@testing-library/react';
+import { banner } from '../../../network/tests/mockData/miscellaneous';
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { TopBanner } from '../topBanner';
+
+it('should render the banner text', async () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(await screen.findByText(banner.message)).toBeInTheDocument();
+});
diff --git a/frontend/src/components/banner/topBanner.js b/frontend/src/components/banner/topBanner.js
deleted file mode 100644
index 7cc1bcf895..0000000000
--- a/frontend/src/components/banner/topBanner.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useLocation } from 'react-router-dom';
-import { useFetchWithAbort } from '../../hooks/UseFetch';
-import { htmlFromMarkdown } from '../../utils/htmlFromMarkdown';
-import './styles.scss';
-
-export function TopBanner() {
- const location = useLocation();
- const [, error, data] = useFetchWithAbort(`system/banner/`);
-
- return (
- <>
- {location.pathname === '/' && data.visible && !error && (
-
- )}
- >
- );
-}
diff --git a/frontend/src/components/banner/topBanner.tsx b/frontend/src/components/banner/topBanner.tsx
new file mode 100644
index 0000000000..3e6163aa2b
--- /dev/null
+++ b/frontend/src/components/banner/topBanner.tsx
@@ -0,0 +1,38 @@
+import { useLocation } from 'react-router-dom';
+import { useFetchWithAbort } from '../../hooks/UseFetch';
+import { htmlFromMarkdown } from '../../utils/htmlFromMarkdown';
+import './styles.scss';
+import { useEffect, useState } from 'react';
+
+export function TopBanner() {
+ const location = useLocation();
+ const [, error, data] = useFetchWithAbort<{
+ message: string;
+ }>(`system/banner/`);
+ const [safeHTML, setSafeHTML] = useState();
+
+ useEffect(() => {
+ (async () => {
+ if (data?.message) {
+ const html = await htmlFromMarkdown(data.message);
+ setSafeHTML(html);
+ }
+ })();
+ }, [data?.message])
+
+ return (
+ <>
+ {/* @ts-expect-error TS Migrations */}
+ {location.pathname === '/' && data?.visible && !error && (
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/basemapMenu.js b/frontend/src/components/basemapMenu.jsx
similarity index 100%
rename from frontend/src/components/basemapMenu.js
rename to frontend/src/components/basemapMenu.jsx
diff --git a/frontend/src/components/button.js b/frontend/src/components/button.js
deleted file mode 100644
index 41ba8bb686..0000000000
--- a/frontend/src/components/button.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Link } from 'react-router-dom';
-
-import { LoadingIcon } from './svgIcons';
-import React from 'react';
-
-const IconSpace = ({ children }) => {children};
-export const AnimatedLoadingIcon = () => (
-
-
-
-);
-
-export function Button({
- onClick,
- children,
- icon,
- className,
- disabled,
- loading = false,
- ...otherProps
-}: Object) {
- return (
-
- );
-}
-
-export function FormSubmitButton({
- children,
- className,
- icon,
- disabledClassName,
- disabled,
- loading = false,
-}: Object) {
- return (
-
- );
-}
-
-export const CustomButton = React.forwardRef(
- ({ onClick, children, icon, className, disabled, loading = false }, ref): Object => {
- return (
-
- );
- },
-);
-
-export function EditButton({ url, children, className = 'mh1 mv1' }: Object) {
- return (
-
- {children}
-
- );
-}
diff --git a/frontend/src/components/button.tsx b/frontend/src/components/button.tsx
new file mode 100644
index 0000000000..c17be84edf
--- /dev/null
+++ b/frontend/src/components/button.tsx
@@ -0,0 +1,112 @@
+import { Link } from 'react-router-dom';
+
+import { LoadingIcon } from './svgIcons';
+import React from 'react';
+
+const IconSpace = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+export const AnimatedLoadingIcon = () => (
+
+
+
+);
+
+export type ButtonProps = React.ButtonHTMLAttributes & {
+ icon?: React.ReactNode;
+ loading?: boolean;
+};
+
+export const Button = React.forwardRef((props, ref) => {
+ const { children, icon, className, loading = false, disabled, ...rest } = props;
+
+ return (
+
+ );
+})
+
+export function FormSubmitButton(
+ props: React.ButtonHTMLAttributes & {
+ icon?: React.ReactNode;
+ loading?: boolean;
+ disabledClassName?: string;
+ },
+) {
+ const {
+ children,
+ icon,
+ className,
+ loading = false,
+ disabled,
+ disabledClassName,
+ ...rest
+ } = props;
+ return (
+
+ );
+}
+
+export const CustomButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ButtonHTMLAttributes & {
+ icon?: React.ReactNode;
+ loading?: boolean;
+ }
+>((props, ref) => {
+ const { children, icon, className, loading = false, disabled, onClick, ...rest } = props;
+ return (
+
+ );
+});
+
+export function EditButton(
+ props: React.AnchorHTMLAttributes & {
+ url: string;
+ },
+) {
+ const { children, className = 'mh1 mv1', url, ...rest } = props;
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/checkCircle.js b/frontend/src/components/checkCircle.js
deleted file mode 100644
index 16a097decd..0000000000
--- a/frontend/src/components/checkCircle.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { CheckIcon } from './svgIcons';
-
-export const CheckCircle = ({ className }: Object) => (
-
-
-
-);
diff --git a/frontend/src/components/checkCircle.jsx b/frontend/src/components/checkCircle.jsx
new file mode 100644
index 0000000000..a4ab446eec
--- /dev/null
+++ b/frontend/src/components/checkCircle.jsx
@@ -0,0 +1,7 @@
+import { CheckIcon } from './svgIcons';
+
+export const CheckCircle = ({ className }) => (
+
+
+
+);
diff --git a/frontend/src/components/code.js b/frontend/src/components/code.jsx
similarity index 100%
rename from frontend/src/components/code.js
rename to frontend/src/components/code.jsx
diff --git a/frontend/src/components/comments/commentInput.js b/frontend/src/components/comments/commentInput.js
deleted file mode 100644
index 96be48c554..0000000000
--- a/frontend/src/components/comments/commentInput.js
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useRef, useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
-import MDEditor from '@uiw/react-md-editor';
-import Tribute from 'tributejs';
-import { FormattedMessage, useIntl } from 'react-intl';
-import { useDropzone } from 'react-dropzone';
-
-import 'tributejs/tribute.css';
-
-import { useOnDrop, useUploadImage } from '../../hooks/UseUploadImage';
-import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
-import HashtagPaste from './hashtagPaste';
-import FileRejections from './fileRejections';
-import DropzoneUploadStatus from './uploadStatus';
-import { DROPZONE_SETTINGS } from '../../config';
-import { htmlFromMarkdown, formatUserNamesToLink } from '../../utils/htmlFromMarkdown';
-import { iconConfig } from './editorIconConfig';
-import messages from './messages';
-import { CurrentUserAvatar } from '../user/avatar';
-
-function CommentInputField({
- comment,
- setComment,
- contributors,
- enableHashtagPaste = false,
- isShowTabNavs = false,
- isShowFooter = false,
- enableContributorsHashtag = false,
- isShowUserPicture = false,
- placeholderMsg = messages.leaveAComment,
- markdownTextareaProps = {},
-}: Object) {
- const token = useSelector((state) => state.auth.token);
- const textareaRef = useRef();
- const isBundle = useRef(false);
- const [isShowPreview, setIsShowPreview] = useState(false);
-
- const appendImgToComment = (url) => setComment(`${comment}\n\n`);
- const [uploadError, uploading, onDrop] = useOnDrop(appendImgToComment);
- const { fileRejections, getRootProps, getInputProps } = useDropzone({
- onDrop,
- ...DROPZONE_SETTINGS,
- });
- const [fileuploadError, fileuploading, uploadImg] = useUploadImage();
-
- const tribute = new Tribute({
- trigger: '@',
- values: async (query, cb) => {
- try {
- if (!query) return cb(contributors.map((username) => ({ username })));
-
- // address trigger js allowSpaces=true issue
- // which triggers this function every keystroke
- const isUsernameAlreadyFetched = /^\[.*?\]\s/.test(query);
- if (isUsernameAlreadyFetched) return;
-
- const res = await fetchLocalJSONAPI(`users/queries/filter/${query}/`, token);
- cb(res.usernames.map((username) => ({ username })));
- } catch (e) {
- return [];
- }
- },
- lookup: 'username',
- fillAttr: 'username',
- selectTemplate: (item) => `@[${item.original.username}]`,
- itemClass: 'w-100 pv2 ph3 bg-tan hover-bg-blue-grey blue-grey hover-white pointer base-font',
- requireLeadingSpace: true,
- noMatchTemplate: null,
- allowSpaces: true,
- searchOpts: {
- skip: true,
- },
- });
-
- useEffect(() => {
- // Make sure the type of contributors is not an array until the attachment happens
- if (textareaRef.current.textarea && !isBundle.current && Array.isArray(contributors)) {
- isBundle.current = true;
- tribute.attach(textareaRef.current.textarea);
- textareaRef.current.textarea.addEventListener('tribute-replaced', (e) => {
- setComment(e.target.value);
- });
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [textareaRef.current, contributors]);
-
- const handleImagePick = async (event) =>
- await uploadImg(event.target.files[0], appendImgToComment, token);
-
- return (
-
- {isShowTabNavs && (
-
- {isShowUserPicture &&
}
-
- setIsShowPreview(false)}
- >
-
-
- setIsShowPreview(true)}
- >
-
-
-
-
- )}
-
- {isShowPreview && (
-
- {comment && (
-
- )}
- {!comment && (
-
-
-
- )}
-
- )}
- {enableHashtagPaste && !isShowPreview && (
-
-
- ,
-
- {enableContributorsHashtag && (
- <>
- ,
-
- >
- )}
-
- )}
-
-
-
- );
-}
-
-export default CommentInputField;
diff --git a/frontend/src/components/comments/commentInput.tsx b/frontend/src/components/comments/commentInput.tsx
new file mode 100644
index 0000000000..8084921e2f
--- /dev/null
+++ b/frontend/src/components/comments/commentInput.tsx
@@ -0,0 +1,209 @@
+import { useRef, useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import MDEditor from '@uiw/react-md-editor';
+import Tribute from 'tributejs';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { useDropzone } from 'react-dropzone';
+
+import 'tributejs/tribute.css';
+
+import { useOnDrop, useUploadImage } from '../../hooks/UseUploadImage';
+import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
+import HashtagPaste from './hashtagPaste';
+import FileRejections from './fileRejections';
+import DropzoneUploadStatus from './uploadStatus';
+import { DROPZONE_SETTINGS } from '../../config';
+import { htmlFromMarkdown, formatUserNamesToLink } from '../../utils/htmlFromMarkdown';
+import { iconConfig } from './editorIconConfig';
+import messages from './messages';
+import { CurrentUserAvatar } from '../user/avatar';
+import { RootStore } from '../../store';
+
+function CommentInputField({
+ comment,
+ setComment,
+ contributors,
+ enableHashtagPaste = false,
+ isShowTabNavs = false,
+ isShowFooter = false,
+ enableContributorsHashtag = false,
+ isShowUserPicture = false,
+ placeholderMsg = messages.leaveAComment,
+ markdownTextareaProps = {},
+}: {
+ comment: string;
+ setComment: (comment: string) => void;
+ contributors: string[] | undefined;
+ enableHashtagPaste?: boolean;
+ isShowTabNavs?: boolean;
+ isShowFooter?: boolean;
+ enableContributorsHashtag?: boolean;
+ isShowUserPicture?: boolean;
+ placeholderMsg?: any;
+ markdownTextareaProps?: any;
+}) {
+ const token = useSelector((state: RootStore) => state.auth.token);
+ const textareaRef = useRef();
+ const isBundle = useRef(false);
+ const [isShowPreview, setIsShowPreview] = useState(false);
+
+ const appendImgToComment = (url: string) => setComment(`${comment}\n\n`);
+ const [uploadError, uploading, onDrop] = useOnDrop(appendImgToComment);
+ const { fileRejections, getRootProps, getInputProps } = useDropzone({
+ onDrop: onDrop ?? undefined,
+ ...DROPZONE_SETTINGS,
+ });
+ const [fileuploadError, fileuploading, uploadImg] = useUploadImage();
+ const [commentHTML, setCommentHTML] = useState();
+
+ const tribute = new Tribute({
+ trigger: '@',
+ values: async (query, cb) => {
+ try {
+ if (!query) return cb(contributors.map((username) => ({ username })));
+
+ // address trigger js allowSpaces=true issue
+ // which triggers this function every keystroke
+ const isUsernameAlreadyFetched = /^\[.*?\]\s/.test(query);
+ if (isUsernameAlreadyFetched) return;
+
+ const res = await fetchLocalJSONAPI(`users/queries/filter/${query}/`, token);
+ cb(res.usernames.map((username) => ({ username })));
+ } catch (e) {
+ return [];
+ }
+ },
+ lookup: 'username',
+ fillAttr: 'username',
+ selectTemplate: (item) => `@[${item.original.username}]`,
+ itemClass: 'w-100 pv2 ph3 bg-tan hover-bg-blue-grey blue-grey hover-white pointer base-font',
+ requireLeadingSpace: true,
+ noMatchTemplate: null,
+ allowSpaces: true,
+ searchOpts: {
+ skip: true,
+ },
+ });
+
+ useEffect(() => {
+ // Make sure the type of contributors is not an array until the attachment happens
+ if (textareaRef.current.textarea && !isBundle.current && Array.isArray(contributors)) {
+ isBundle.current = true;
+ tribute.attach(textareaRef.current.textarea);
+ textareaRef.current.textarea.addEventListener('tribute-replaced', (e) => {
+ setComment(e.target.value);
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [textareaRef.current, contributors]);
+
+ useEffect(() => {
+ (async () => {
+ setCommentHTML(await htmlFromMarkdown(formatUserNamesToLink(comment)));
+ })();
+ }, [commentHTML, comment]);
+
+ const handleImagePick = async (event) =>
+ await uploadImg(event.target.files[0], appendImgToComment, token);
+
+ return (
+
+ {isShowTabNavs && (
+
+ {isShowUserPicture &&
}
+
+ setIsShowPreview(false)}
+ >
+
+
+ setIsShowPreview(true)}
+ >
+
+
+
+
+ )}
+
+ {isShowPreview && (
+
+ {comment && (
+
+ )}
+ {!comment && (
+
+
+
+ )}
+
+ )}
+ {enableHashtagPaste && !isShowPreview && (
+
+
+ ,
+
+ {enableContributorsHashtag && (
+ <>
+ ,
+
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
+
+export default CommentInputField;
diff --git a/frontend/src/components/comments/editorIconConfig.js b/frontend/src/components/comments/editorIconConfig.js
deleted file mode 100644
index 7b3178a89a..0000000000
--- a/frontend/src/components/comments/editorIconConfig.js
+++ /dev/null
@@ -1,171 +0,0 @@
-import {
- bold,
- italic,
- quote,
- link,
- unorderedListCommand,
- selectWord,
- orderedListCommand,
-} from '@uiw/react-md-editor';
-
-const ICON_SIZE = 14;
-
-export const iconConfig = {
- bold: {
- ...bold,
- icon: (
-
- ),
- },
- italic: {
- ...italic,
- icon: (
-
- ),
- },
- quote: {
- ...quote,
- icon: (
-
- ),
- },
- link: {
- ...link,
- icon: (
-
- ),
- },
- unorderedListCommand: {
- ...unorderedListCommand,
- icon: (
-
- ),
- },
- orderedListCommand: {
- ...orderedListCommand,
- icon: (
-
- ),
- },
- mention: {
- name: 'mention',
- keyCommand: 'mention',
- value: '@',
- buttonProps: { 'aria-label': 'Mention user', title: 'Mention user' },
- icon: (
-
- ),
- execute: (state, api) => {
- const newSelectionRange = selectWord({ text: state.text, selection: state.selection });
- const state1 = api.setSelectionRange(newSelectionRange);
- const state2 = api.replaceSelection(`@${state1.selectedText}`);
- api.setSelectionRange({
- start: state2.selection.end - state1.selectedText.length,
- end: state2.selection.end,
- });
- },
- },
- upload: {
- name: 'upload',
- keyCommand: 'upload',
- buttonProps: { 'aria-label': 'Upload user', title: 'Upload image' },
- icon: (
-
- ),
- execute: () => {
- document.getElementById('image_picker').click();
- },
- },
- // The backend converts markdown into HTML, so youtube embed iframe works on the preview mode,
- // uncomment this when backend has support for converting our custom youtube markdown into iframes
- // youtube: {
- // name: 'youtube',
- // keyCommand: 'youtube',
- // value: '::youtube[]',
- // buttonProps: { 'aria-label': 'Add YouTube video', title: 'Add YouTube video' },
- // icon: (
- //
- // ),
- // execute: (state, api) => {
- // const newSelectionRange = selectWord({ text: state.text, selection: state.selection });
- // const state1 = api.setSelectionRange(newSelectionRange);
- // const state2 = api.replaceSelection(`::youtube[${state1.selectedText}]`);
- // api.setSelectionRange({
- // start: state2.selection.end - 1,
- // end: state2.selection.end - 1,
- // });
- // },
- // },
-};
diff --git a/frontend/src/components/comments/editorIconConfig.tsx b/frontend/src/components/comments/editorIconConfig.tsx
new file mode 100644
index 0000000000..9b5ae6d82a
--- /dev/null
+++ b/frontend/src/components/comments/editorIconConfig.tsx
@@ -0,0 +1,174 @@
+import {
+ bold,
+ italic,
+ quote,
+ link,
+ unorderedListCommand,
+ selectWord,
+ orderedListCommand,
+ ExecuteState,
+ TextAreaTextApi,
+} from '@uiw/react-md-editor';
+
+const ICON_SIZE = 14;
+
+export const iconConfig = {
+ bold: {
+ ...bold,
+ icon: (
+
+ ),
+ },
+ italic: {
+ ...italic,
+ icon: (
+
+ ),
+ },
+ quote: {
+ ...quote,
+ icon: (
+
+ ),
+ },
+ link: {
+ ...link,
+ icon: (
+
+ ),
+ },
+ unorderedListCommand: {
+ ...unorderedListCommand,
+ icon: (
+
+ ),
+ },
+ orderedListCommand: {
+ ...orderedListCommand,
+ icon: (
+
+ ),
+ },
+ mention: {
+ name: 'mention',
+ keyCommand: 'mention',
+ value: '@',
+ buttonProps: { 'aria-label': 'Mention user', title: 'Mention user' },
+ icon: (
+
+ ),
+ execute: (state: ExecuteState, api: TextAreaTextApi) => {
+ // @ts-expect-error TS Migrations - we need to look at what prefix does - can't find it in the docs'
+ const newSelectionRange = selectWord({ text: state.text, selection: state.selection });
+ const state1 = api.setSelectionRange(newSelectionRange);
+ const state2 = api.replaceSelection(`@${state1.selectedText}`);
+ api.setSelectionRange({
+ start: state2.selection.end - state1.selectedText.length,
+ end: state2.selection.end,
+ });
+ },
+ },
+ upload: {
+ name: 'upload',
+ keyCommand: 'upload',
+ buttonProps: { 'aria-label': 'Upload user', title: 'Upload image' },
+ icon: (
+
+ ),
+ execute: () => {
+ document.getElementById('image_picker')?.click();
+ },
+ },
+ // The backend converts markdown into HTML, so youtube embed iframe works on the preview mode,
+ // uncomment this when backend has support for converting our custom youtube markdown into iframes
+ // youtube: {
+ // name: 'youtube',
+ // keyCommand: 'youtube',
+ // value: '::youtube[]',
+ // buttonProps: { 'aria-label': 'Add YouTube video', title: 'Add YouTube video' },
+ // icon: (
+ //
+ // ),
+ // execute: (state, api) => {
+ // const newSelectionRange = selectWord({ text: state.text, selection: state.selection });
+ // const state1 = api.setSelectionRange(newSelectionRange);
+ // const state2 = api.replaceSelection(`::youtube[${state1.selectedText}]`);
+ // api.setSelectionRange({
+ // start: state2.selection.end - 1,
+ // end: state2.selection.end - 1,
+ // });
+ // },
+ // },
+};
diff --git a/frontend/src/components/comments/fileRejections.js b/frontend/src/components/comments/fileRejections.js
deleted file mode 100644
index 005541cc22..0000000000
--- a/frontend/src/components/comments/fileRejections.js
+++ /dev/null
@@ -1,20 +0,0 @@
-const FileRejections = ({ files }: Object) => {
- // Component that receives the rejected files from Dropzone
- return (
-
- {files.map(({ file, errors }) => (
- -
- {file.path} (
- {errors.map((e) => (
-
- {e.message},
-
- ))}
- )
-
- ))}
-
- );
-};
-
-export default FileRejections;
diff --git a/frontend/src/components/comments/fileRejections.tsx b/frontend/src/components/comments/fileRejections.tsx
new file mode 100644
index 0000000000..f9fca99e8c
--- /dev/null
+++ b/frontend/src/components/comments/fileRejections.tsx
@@ -0,0 +1,27 @@
+import { FileError, FileRejection, FileWithPath } from "react-dropzone";
+
+const FileRejections = ({ files }: {
+ files: FileRejection[];
+}) => {
+ // Component that receives the rejected files from Dropzone
+ return (
+
+ {files.map(({ file, errors }: {
+ file: FileWithPath;
+ errors: FileError[];
+ }) => (
+ -
+ {file.path} (
+ {errors.map((e) => (
+
+ {e.message},
+
+ ))}
+ )
+
+ ))}
+
+ );
+};
+
+export default FileRejections;
diff --git a/frontend/src/components/comments/hashtagPaste.js b/frontend/src/components/comments/hashtagPaste.js
deleted file mode 100644
index 63eef64fc3..0000000000
--- a/frontend/src/components/comments/hashtagPaste.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useIntl } from 'react-intl';
-
-import messages from './messages';
-
-export default function HashtagPaste({ text, setFn, hashtag, className }: Object) {
- const intl = useIntl();
- return (
- setFn(text ? `${text} ${hashtag}` : `${hashtag} `)}
- title={intl.formatMessage(messages[`${hashtag.replace('#', '')}HashtagTip`], {
- hashtag: hashtag,
- })}
- >
- {hashtag}
-
- );
-}
diff --git a/frontend/src/components/comments/hashtagPaste.tsx b/frontend/src/components/comments/hashtagPaste.tsx
new file mode 100644
index 0000000000..87e16c23c2
--- /dev/null
+++ b/frontend/src/components/comments/hashtagPaste.tsx
@@ -0,0 +1,25 @@
+import { useIntl } from 'react-intl';
+
+import messages from './messages';
+
+export default function HashtagPaste({ text, setFn, hashtag, className }: {
+ text: string;
+ setFn: (text: string) => void;
+ hashtag: string;
+ className: string;
+}) {
+ const intl = useIntl();
+ return (
+ setFn(text ? `${text} ${hashtag}` : `${hashtag} `)}
+ // @ts-expect-error TS Migrations
+ title={intl.formatMessage(messages[`${hashtag.replace('#', '')}HashtagTip`], {
+ hashtag: hashtag,
+ })}
+ >
+ {hashtag}
+
+ );
+}
diff --git a/frontend/src/components/comments/messages.js b/frontend/src/components/comments/messages.ts
similarity index 100%
rename from frontend/src/components/comments/messages.js
rename to frontend/src/components/comments/messages.ts
diff --git a/frontend/src/components/comments/status.js b/frontend/src/components/comments/status.js
deleted file mode 100644
index 1a2987c211..0000000000
--- a/frontend/src/components/comments/status.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { Alert } from '../alert';
-
-export const MessageStatus = ({ status, comment }) => {
- if (status === 'success' && !comment) {
- return (
-
-
-
- );
- }
- if (status === 'error') {
- return (
-
-
-
- );
- }
- return null;
-};
diff --git a/frontend/src/components/comments/status.tsx b/frontend/src/components/comments/status.tsx
new file mode 100644
index 0000000000..446874adf6
--- /dev/null
+++ b/frontend/src/components/comments/status.tsx
@@ -0,0 +1,25 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { Alert } from '../alert';
+
+export const MessageStatus = ({ status, comment }: {
+ status: 'success' | 'error',
+ comment?: string,
+}) => {
+ if (status === 'success' && !comment) {
+ return (
+
+
+
+ );
+ }
+ if (status === 'error') {
+ return (
+
+
+
+ );
+ }
+ return null;
+};
diff --git a/frontend/src/components/comments/tests/fileRejections.test.js b/frontend/src/components/comments/tests/fileRejections.test.js
deleted file mode 100644
index 0391f7cba8..0000000000
--- a/frontend/src/components/comments/tests/fileRejections.test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import FileRejections from '../fileRejections';
-
-describe('FileRejections', () => {
- it('with an empty file array renders only a ul element', () => {
- const { container } = render();
- expect(container.querySelector('ul').children.length).toBe(0);
- });
-
- it('renders a li element to each file', () => {
- const files = [
- {
- file: { path: '/home/test/file.csv' },
- errors: [
- { code: 1, message: 'Format not supported' },
- { code: 2, message: 'Size bigger than 100kb' },
- ],
- },
- {
- file: { path: '/home/test/file.txt' },
- errors: [{ code: 1, message: 'Format not supported' }],
- },
- ];
- const { container } = render();
- expect(container.querySelectorAll('li').length).toBe(2);
- expect(container.querySelectorAll('li')[0].className).toBe('red');
- expect(screen.queryByText(/file.csv/).className).toBe('red');
- expect(screen.queryByText(/file.txt/).className).toBe('red');
- expect(screen.queryAllByText(/Format not supported/).length).toBe(2);
- expect(screen.queryAllByText(/Format not supported/)[0].className).toBe('dib pr2');
- expect(screen.queryByText(/Size bigger than 100kb/).className).toBe('dib pr2');
- });
-});
diff --git a/frontend/src/components/comments/tests/fileRejections.test.tsx b/frontend/src/components/comments/tests/fileRejections.test.tsx
new file mode 100644
index 0000000000..c0f2b6c26d
--- /dev/null
+++ b/frontend/src/components/comments/tests/fileRejections.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from '@testing-library/react';
+
+import FileRejections from '../fileRejections';
+
+describe('FileRejections', () => {
+ it('with an empty file array renders only a ul element', () => {
+ const { container } = render();
+ expect(container.querySelector('ul')?.children.length).toBe(0);
+ });
+
+ it('renders a li element to each file', () => {
+ const files = [
+ {
+ file: { path: '/home/test/file.csv' },
+ errors: [
+ { code: 1, message: 'Format not supported' },
+ { code: 2, message: 'Size bigger than 100kb' },
+ ],
+ },
+ {
+ file: { path: '/home/test/file.txt' },
+ errors: [{ code: 1, message: 'Format not supported' }],
+ },
+ ];
+ render();
+ expect(screen.getAllByRole("listitem").length).toBe(2);
+ expect(screen.getAllByRole("listitem")[0].className).toBe("red");
+ expect(screen.queryByText(/file.csv/)?.className).toBe('red');
+ expect(screen.queryByText(/file.txt/)?.className).toBe('red');
+ expect(screen.queryAllByText(/Format not supported/).length).toBe(2);
+ expect(screen.queryAllByText(/Format not supported/)[0].className).toBe('dib pr2');
+ expect(screen.queryByText(/Size bigger than 100kb/)?.className).toBe('dib pr2');
+ });
+});
diff --git a/frontend/src/components/comments/tests/hashtagPaste.test.js b/frontend/src/components/comments/tests/hashtagPaste.test.js
deleted file mode 100644
index af8bfc9f20..0000000000
--- a/frontend/src/components/comments/tests/hashtagPaste.test.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import HashtagPaste from '../hashtagPaste';
-import userEvent from '@testing-library/user-event';
-
-test('HashtagPaste with an empty text string', async () => {
- const setFn = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- expect(screen.getByText('#managers').className).toBe('bb pointer pt2 f6');
- expect(screen.getByText('#managers').style.borderBottomStyle).toBe('dashed');
- expect(screen.getByText('#managers').title).toBeTruthy();
- await user.click(screen.getByText('#managers'));
- expect(setFn).toHaveBeenCalledWith('#managers ');
-});
-
-test('HashtagPaste with a text string', async () => {
- const setFn = jest.fn();
- const user = userEvent.setup();
- render(
-
-
- ,
- );
- expect(screen.getByText('#managers').className).toBe('bb pointer pt2 f6');
- expect(screen.getByText('#managers').style.borderBottomStyle).toBe('dashed');
- await user.click(screen.getByText('#managers'));
- expect(setFn).toHaveBeenCalledWith('My comment #managers');
-});
diff --git a/frontend/src/components/comments/tests/hashtagPaste.test.tsx b/frontend/src/components/comments/tests/hashtagPaste.test.tsx
new file mode 100644
index 0000000000..72bd579c62
--- /dev/null
+++ b/frontend/src/components/comments/tests/hashtagPaste.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import HashtagPaste from '../hashtagPaste';
+import userEvent from '@testing-library/user-event';
+
+test('HashtagPaste with an empty text string', async () => {
+ const setFn = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('#managers').className).toBe('bb pointer pt2 f6');
+ expect(screen.getByText('#managers').style.borderBottomStyle).toBe('dashed');
+ expect(screen.getByText('#managers').title).toBeTruthy();
+ await user.click(screen.getByText('#managers'));
+ expect(setFn).toHaveBeenCalledWith('#managers ');
+});
+
+test('HashtagPaste with a text string', async () => {
+ const setFn = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('#managers').className).toBe('bb pointer pt2 f6');
+ expect(screen.getByText('#managers').style.borderBottomStyle).toBe('dashed');
+ await user.click(screen.getByText('#managers'));
+ expect(setFn).toHaveBeenCalledWith('My comment #managers');
+});
diff --git a/frontend/src/components/comments/tests/status.test.js b/frontend/src/components/comments/tests/status.test.js
deleted file mode 100644
index e8c9521d72..0000000000
--- a/frontend/src/components/comments/tests/status.test.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import { MessageStatus } from '../status';
-
-describe('MessageStatus', () => {
- it('with status = error', () => {
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('An error occurred while sending message.')).toBeInTheDocument();
- expect(container.querySelector('.dark-red')).toBeInTheDocument();
- expect(container.querySelector('.bg-washed-red')).toBeInTheDocument();
- expect(container.querySelector('.di')).toBeInTheDocument();
- expect(container.querySelector('.pa2')).toBeInTheDocument();
- });
- it('with status = success', () => {
- const { container } = render(
-
-
- ,
- );
- expect(screen.getByText('Message sent.')).toBeInTheDocument();
- expect(container.querySelector('.dark-green')).toBeInTheDocument();
- expect(container.querySelector('.bg-washed-green')).toBeInTheDocument();
- expect(container.querySelector('.di')).toBeInTheDocument();
- expect(container.querySelector('.pa2')).toBeInTheDocument();
- });
- it('with status = success and a comment', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Message sent.')).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/comments/tests/status.test.tsx b/frontend/src/components/comments/tests/status.test.tsx
new file mode 100644
index 0000000000..7d87bf25d6
--- /dev/null
+++ b/frontend/src/components/comments/tests/status.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import { MessageStatus } from '../status';
+
+describe('MessageStatus', () => {
+ it('with status = error', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('An error occurred while sending message.')).toBeInTheDocument();
+ expect(container.querySelector('.dark-red')).toBeInTheDocument();
+ expect(container.querySelector('.bg-washed-red')).toBeInTheDocument();
+ expect(container.querySelector('.di')).toBeInTheDocument();
+ expect(container.querySelector('.pa2')).toBeInTheDocument();
+ });
+ it('with status = success', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByText('Message sent.')).toBeInTheDocument();
+ expect(container.querySelector('.dark-green')).toBeInTheDocument();
+ expect(container.querySelector('.bg-washed-green')).toBeInTheDocument();
+ expect(container.querySelector('.di')).toBeInTheDocument();
+ expect(container.querySelector('.pa2')).toBeInTheDocument();
+ });
+ it('with status = success and a comment', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Message sent.')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/comments/tests/uploadStatus.test.js b/frontend/src/components/comments/tests/uploadStatus.test.js
deleted file mode 100644
index a86ad04a04..0000000000
--- a/frontend/src/components/comments/tests/uploadStatus.test.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import DropzoneUploadStatus from '../uploadStatus';
-
-describe('DropzoneUploadStatus when', () => {
- it('uploading and uploadError are false', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
- expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
- });
- it('uploading and uploadError are undefined', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
- expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
- });
- it('uploading and uploadError are null', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
- expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
- });
- it('uploading is true and uploadError is false', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Uploading file...')).toBeInTheDocument();
- expect(screen.queryByText('Uploading file...').className).toBe('blue-grey f6 pt3 db');
- expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
- });
- it('uploading is false and uploadError is true', () => {
- render(
-
-
- ,
- );
- expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
- expect(screen.queryByText('The image upload failed.')).toBeInTheDocument();
- expect(screen.queryByText('The image upload failed.').className).toBe('red f6 pt3 db');
- });
-});
diff --git a/frontend/src/components/comments/tests/uploadStatus.test.tsx b/frontend/src/components/comments/tests/uploadStatus.test.tsx
new file mode 100644
index 0000000000..a7fb1c6909
--- /dev/null
+++ b/frontend/src/components/comments/tests/uploadStatus.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+import DropzoneUploadStatus from '../uploadStatus';
+
+describe('DropzoneUploadStatus when', () => {
+ it('uploading and uploadError are false', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
+ expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
+ });
+ it('uploading and uploadError are undefined', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
+ expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
+ });
+ it('uploading and uploadError are null', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
+ expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
+ });
+ it('uploading is true and uploadError is false', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Uploading file...')).toBeInTheDocument();
+ expect(screen.queryByText('Uploading file...')?.className).toBe('blue-grey f6 pt3 db');
+ expect(screen.queryByText('The image upload failed.')).not.toBeInTheDocument();
+ });
+ it('uploading is false and uploadError is true', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByText('Uploading file...')).not.toBeInTheDocument();
+ expect(screen.queryByText('The image upload failed.')).toBeInTheDocument();
+ expect(screen.queryByText('The image upload failed.')?.className).toBe('red f6 pt3 db');
+ });
+});
diff --git a/frontend/src/components/comments/uploadStatus.js b/frontend/src/components/comments/uploadStatus.js
deleted file mode 100644
index a6c5448463..0000000000
--- a/frontend/src/components/comments/uploadStatus.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-
-const DropzoneUploadStatus = ({ uploadError, uploading }: Object) => {
- return (
- <>
- {uploadError && (
-
-
-
- )}
- {uploading && (
-
-
-
- )}
- >
- );
-};
-
-export default DropzoneUploadStatus;
diff --git a/frontend/src/components/comments/uploadStatus.tsx b/frontend/src/components/comments/uploadStatus.tsx
new file mode 100644
index 0000000000..74eeb3e758
--- /dev/null
+++ b/frontend/src/components/comments/uploadStatus.tsx
@@ -0,0 +1,25 @@
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+
+const DropzoneUploadStatus = ({ uploadError, uploading }: {
+ uploadError?: boolean | null;
+ uploading?: boolean | null;
+}) => {
+ return (
+ <>
+ {uploadError && (
+
+
+
+ )}
+ {uploading && (
+
+
+
+ )}
+ >
+ );
+};
+
+export default DropzoneUploadStatus;
diff --git a/frontend/src/components/contributions/messages.js b/frontend/src/components/contributions/messages.ts
similarity index 100%
rename from frontend/src/components/contributions/messages.js
rename to frontend/src/components/contributions/messages.ts
diff --git a/frontend/src/components/contributions/myProjectsDropdown.js b/frontend/src/components/contributions/myProjectsDropdown.js
deleted file mode 100644
index 36c9afd24e..0000000000
--- a/frontend/src/components/contributions/myProjectsDropdown.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useSelector } from 'react-redux';
-import Select from 'react-select';
-import { FormattedMessage } from 'react-intl';
-import { useFetch } from '../../hooks/UseFetch';
-import messages from './messages';
-
-export default function MyProjectsDropdown({ className, setQuery, allQueryParams }) {
- const username = useSelector((state) => state.auth.userDetails.username);
- const [, , projects] = useFetch(`projects/queries/${username}/touched/`);
-
- const onSortSelect = (projectId) => {
- setQuery(
- {
- ...allQueryParams,
- page: undefined,
- projectId,
- },
- 'pushIn',
- );
- };
-
- const options = projects.mappedProjects?.map(({ projectId }) => ({
- label: projectId,
- value: projectId,
- }));
-
- return (
-
-
- );
-}
diff --git a/frontend/src/components/contributions/myProjectsDropdown.tsx b/frontend/src/components/contributions/myProjectsDropdown.tsx
new file mode 100644
index 0000000000..59aff92b35
--- /dev/null
+++ b/frontend/src/components/contributions/myProjectsDropdown.tsx
@@ -0,0 +1,49 @@
+import { useSelector } from 'react-redux';
+import Select from 'react-select';
+import { FormattedMessage } from 'react-intl';
+import { useFetch } from '../../hooks/UseFetch';
+import messages from './messages';
+import { RootStore } from '../../store';
+
+export default function MyProjectsDropdown({ className, setQuery, allQueryParams }: {
+ className?: string;
+ setQuery: any;
+ allQueryParams: any;
+}) {
+ const username = useSelector((state: RootStore) => state.auth.userDetails?.username);
+ const [, , projects] = useFetch(`projects/queries/${username}/touched/`);
+
+ const onSortSelect = (projectId: string) => {
+ setQuery(
+ {
+ ...allQueryParams,
+ page: undefined,
+ projectId,
+ },
+ 'pushIn',
+ );
+ };
+
+ // @ts-expect-error TS Migrations
+ const options = projects.mappedProjects?.map(({ projectId }: {
+ projectId: string;
+ }) => ({
+ label: projectId,
+ value: projectId,
+ }));
+
+ return (
+
+
+ );
+}
diff --git a/frontend/src/components/contributions/myTasksNav.js b/frontend/src/components/contributions/myTasksNav.js
deleted file mode 100644
index 2e300d1dd2..0000000000
--- a/frontend/src/components/contributions/myTasksNav.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Link } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { useTaskContributionQueryParams, stringify } from '../../hooks/UseTaskContributionAPI';
-import MyTasksOrderDropdown from './myTasksOrderDropdown';
-import MyProjectsDropdown from './myProjectsDropdown';
-
-export const isActiveButton = (buttonName, contributionQuery) => {
- let isActive = false;
- try {
- if (contributionQuery.status.includes(buttonName)) {
- isActive = true;
- }
- } catch {
- if (contributionQuery.projectStatus === buttonName) {
- isActive = true;
- }
- if (buttonName === 'All' && !contributionQuery.projectStatus && !contributionQuery.status) {
- isActive = true;
- }
- }
-
- if (isActive) {
- return 'bg-blue-grey white fw5';
- } else {
- return 'bg-white blue-grey';
- }
-};
-
-export const MyTasksNav = (props) => {
- const [contributionsQuery, setContributionsQuery] = useTaskContributionQueryParams();
-
- const linkCombo = 'dib mh1 mb2 link ph3 f6 pv2 ba b--grey-light';
- const notAnyFilter = !stringify(contributionsQuery);
- return (
- /* mb1 mb2-ns (removed for map, but now small gap for more-filters) */
-
- );
-};
diff --git a/frontend/src/components/contributions/myTasksNav.tsx b/frontend/src/components/contributions/myTasksNav.tsx
new file mode 100644
index 0000000000..89ac7deb57
--- /dev/null
+++ b/frontend/src/components/contributions/myTasksNav.tsx
@@ -0,0 +1,100 @@
+import { Link } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+
+import messages from './messages';
+import { useTaskContributionQueryParams, stringify } from '../../hooks/UseTaskContributionAPI';
+import MyTasksOrderDropdown from './myTasksOrderDropdown';
+import MyProjectsDropdown from './myProjectsDropdown';
+
+export const isActiveButton = (buttonName: string, contributionQuery: {
+ [key: string]: any
+}) => {
+ let isActive = false;
+ try {
+ if (contributionQuery.status.includes(buttonName)) {
+ isActive = true;
+ }
+ } catch {
+ if (contributionQuery.projectStatus === buttonName) {
+ isActive = true;
+ }
+ if (buttonName === 'All' && !contributionQuery.projectStatus && !contributionQuery.status) {
+ isActive = true;
+ }
+ }
+
+ if (isActive) {
+ return 'bg-blue-grey white fw5';
+ } else {
+ return 'bg-white blue-grey';
+ }
+};
+
+export const MyTasksNav = () => {
+ const [contributionsQuery, setContributionsQuery] = useTaskContributionQueryParams();
+
+ const linkCombo = 'dib mh1 mb2 link ph3 f6 pv2 ba b--grey-light';
+ const notAnyFilter = !stringify(contributionsQuery);
+ return (
+ /* mb1 mb2-ns (removed for map, but now small gap for more-filters) */
+
+ );
+};
diff --git a/frontend/src/components/contributions/myTasksOrderDropdown.js b/frontend/src/components/contributions/myTasksOrderDropdown.js
deleted file mode 100644
index 4e0db9f38f..0000000000
--- a/frontend/src/components/contributions/myTasksOrderDropdown.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-import { Dropdown } from '../dropdown';
-import messages from './messages';
-
-export default function MyTasksOrderDropdown({ className, setQuery, allQueryParams }) {
- const options = [
- {
- label: ,
- value: '-action_date',
- },
- {
- label: ,
- value: '-project_id',
- },
- ];
-
- const onSortSelect = (arr) =>
- setQuery(
- {
- ...allQueryParams,
- page: undefined,
- orderBy: arr[0].value,
- },
- 'pushIn',
- );
-
- return (
- }
- className={`ba b--grey-light bg-white mr1 v-mid pv2 ${className || ''}`}
- />
- );
-}
diff --git a/frontend/src/components/contributions/myTasksOrderDropdown.tsx b/frontend/src/components/contributions/myTasksOrderDropdown.tsx
new file mode 100644
index 0000000000..d0d2e5386c
--- /dev/null
+++ b/frontend/src/components/contributions/myTasksOrderDropdown.tsx
@@ -0,0 +1,45 @@
+import { FormattedMessage, useIntl } from 'react-intl';
+import { Dropdown } from '../dropdown';
+import messages from './messages';
+
+export default function MyTasksOrderDropdown({ className, setQuery, allQueryParams }: {
+ className?: string;
+ setQuery: Function;
+ allQueryParams: unknown;
+}) {
+ const intl = useIntl();
+ const options = [
+ {
+ label: intl.formatMessage(messages.recentlyEdited),
+ value: '-action_date',
+ },
+ {
+ label: intl.formatMessage(messages.projectId),
+ value: '-project_id',
+ },
+ ];
+
+ const onSortSelect = (arr: unknown[]) => {
+ setQuery(
+ {
+ // @ts-expect-error TS Migrations
+ ...allQueryParams,
+ page: undefined,
+ // @ts-expect-error TS Migrations
+ orderBy: arr[0].value,
+ },
+ 'pushIn',
+ );
+ }
+
+ return (
+ }
+ className={`ba b--grey-light bg-white mr1 v-mid pv2 ${className || ''}`}
+ />
+ );
+}
diff --git a/frontend/src/components/contributions/taskCard.js b/frontend/src/components/contributions/taskCard.js
deleted file mode 100644
index b5e811bd86..0000000000
--- a/frontend/src/components/contributions/taskCard.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { useState } from 'react';
-import { Link } from 'react-router-dom';
-import Popup from 'reactjs-popup';
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import messages from './messages';
-import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
-import { ListIcon, ResumeIcon, ClockIcon, CommentIcon } from '../svgIcons';
-import { TaskStatus } from '../taskSelection/taskList';
-import { TaskActivity } from '../taskSelection/taskActivity';
-
-export function TaskCard({
- taskId,
- projectId,
- taskStatus,
- lockHolder,
- title,
- taskHistory,
- taskAnnotation,
- isUndoable,
- autoUnlockSeconds,
- lastUpdated,
- lastUpdatedBy,
- numberOfComments,
-}: Object) {
- const [isHovered, setHovered] = useState(false);
- const taskLink = `/projects/${projectId}/tasks?search=${taskId}`;
- const intl = useIntl();
-
- const timeToAutoUnlock =
- lastUpdated &&
- autoUnlockSeconds &&
- lockHolder &&
- new Date(+new Date(lastUpdated) + autoUnlockSeconds * 1000);
-
- return (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- >
-
-
-
-
-
-
-
-
-
- }}
- />
-
-
-
-
-
-
-
-
- {lockHolder && (
-
-
-
- }}
- />
-
-
- )}
-
-
-
- {numberOfComments ? (
-
-
- {numberOfComments}
-
- ) : (
- ''
- )}
-
- }
- modal
- closeOnDocumentClick
- nested
- >
- {(close) => (
-
- )}
-
- {isHovered &&
- ['READY', 'LOCKED_FOR_MAPPING', 'LOCKED_FOR_VALIDATION', 'INVALIDATED'].includes(
- taskStatus,
- ) && (
-
-
-
-
-
-
- )}
-
-
-
- );
-}
diff --git a/frontend/src/components/contributions/taskCard.jsx b/frontend/src/components/contributions/taskCard.jsx
new file mode 100644
index 0000000000..aa453d0f56
--- /dev/null
+++ b/frontend/src/components/contributions/taskCard.jsx
@@ -0,0 +1,123 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import Popup from 'reactjs-popup';
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import messages from './messages';
+import { RelativeTimeWithUnit } from '../../utils/formattedRelativeTime';
+import { ListIcon, ResumeIcon, ClockIcon, CommentIcon } from '../svgIcons';
+import { TaskStatus } from '../taskSelection/taskList';
+import { TaskActivity } from '../taskSelection/taskActivity';
+
+export function TaskCard({
+ taskId,
+ projectId,
+ taskStatus,
+ lockHolder,
+ title,
+ taskHistory,
+ taskAnnotation,
+ isUndoable,
+ autoUnlockSeconds,
+ lastUpdated,
+ lastUpdatedBy,
+ numberOfComments,
+}) {
+ const [isHovered, setHovered] = useState(false);
+ const taskLink = `/projects/${projectId}/tasks?search=${taskId}`;
+ const intl = useIntl();
+
+ const timeToAutoUnlock =
+ lastUpdated &&
+ autoUnlockSeconds &&
+ lockHolder &&
+ new Date(+new Date(lastUpdated) + autoUnlockSeconds * 1000);
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+
+
+
+
+
+
+
+
+
+ }}
+ />
+
+
+
+
+
+
+
+
+ {lockHolder && (
+
+
+
+ }}
+ />
+
+
+ )}
+
+
+
+ {numberOfComments ? (
+
+
+ {numberOfComments}
+
+ ) : (
+ ''
+ )}
+
+ }
+ modal
+ closeOnDocumentClick
+ nested
+ >
+ {(close) => (
+
+ )}
+
+ {isHovered &&
+ ['READY', 'LOCKED_FOR_MAPPING', 'LOCKED_FOR_VALIDATION', 'INVALIDATED'].includes(
+ taskStatus,
+ ) && (
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/contributions/taskResults.js b/frontend/src/components/contributions/taskResults.js
deleted file mode 100644
index 8c6aa61362..0000000000
--- a/frontend/src/components/contributions/taskResults.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-import ReactPlaceholder from 'react-placeholder';
-import 'react-placeholder/lib/reactPlaceholder.css';
-
-import messages from './messages';
-import { TaskCard } from './taskCard';
-
-export const TaskResults = (props) => {
- const state = props.state;
-
- return (
-
- {!state.isLoading && !state.isError && (
-
- ,
- }}
- />
-
- )}
- {state.isError && (
-
-
-
-
-
-
- )}
-
-
-
-
-
-
- );
-};
-
-export const TaskCards = ({ pageOfCards }) => {
- if (pageOfCards?.length === 0) {
- return (
-
-
-
- );
- }
-
- return pageOfCards?.map((card) => );
-};
diff --git a/frontend/src/components/contributions/taskResults.tsx b/frontend/src/components/contributions/taskResults.tsx
new file mode 100644
index 0000000000..cdd045d445
--- /dev/null
+++ b/frontend/src/components/contributions/taskResults.tsx
@@ -0,0 +1,75 @@
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+import ReactPlaceholder from 'react-placeholder';
+import 'react-placeholder/lib/reactPlaceholder.css';
+
+import messages from './messages';
+import { TaskCard } from './taskCard';
+
+export interface TaskCardProps {
+ projectId: number;
+ taskId: number;
+}
+
+interface TaskResultsProps {
+ state: {
+ isLoading: boolean;
+ isError: boolean;
+ tasks: TaskCardProps[];
+ pagination?: {
+ total: number;
+ };
+ };
+ className?: string;
+ retryFn: () => void;
+}
+
+export const TaskResults: React.FC = (props) => {
+ const state = props.state;
+
+ return (
+
+ {!state.isLoading && !state.isError && (
+
+ ,
+ }}
+ />
+
+ )}
+ {state.isError && (
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+interface TaskCardsProps {
+ pageOfCards?: TaskCardProps[];
+}
+
+export const TaskCards: React.FC = ({ pageOfCards }) => {
+ if (pageOfCards?.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return pageOfCards?.map((card) => );
+};
diff --git a/frontend/src/components/contributions/tests/myProjectsDropdown.test.js b/frontend/src/components/contributions/tests/myProjectsDropdown.test.js
deleted file mode 100644
index 220546b27a..0000000000
--- a/frontend/src/components/contributions/tests/myProjectsDropdown.test.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import selectEvent from 'react-select-event';
-
-import MyProjectsDropdown from '../myProjectsDropdown';
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-
-it('displays placeholder and typed text on type', async () => {
- const setQueryMock = jest.fn();
- render(
-
-
- ,
- );
-
- await selectEvent.select(screen.getByRole('combobox'), '#8629');
- expect(setQueryMock).toHaveBeenCalled();
-});
diff --git a/frontend/src/components/contributions/tests/myProjectsDropdown.test.tsx b/frontend/src/components/contributions/tests/myProjectsDropdown.test.tsx
new file mode 100644
index 0000000000..6d3f92c3ed
--- /dev/null
+++ b/frontend/src/components/contributions/tests/myProjectsDropdown.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import selectEvent from 'react-select-event';
+import MyProjectsDropdown from '../myProjectsDropdown';
+import { ReduxIntlProviders } from '../../../utils/testWithIntl';
+
+it('displays placeholder and typed text on type', async () => {
+ const setQueryMock = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await selectEvent.select(screen.getByRole('combobox'), '#8629');
+ expect(setQueryMock).toHaveBeenCalled();
+});
diff --git a/frontend/src/components/contributions/tests/myTasksNav.test.js b/frontend/src/components/contributions/tests/myTasksNav.test.js
deleted file mode 100644
index cf4438ddc6..0000000000
--- a/frontend/src/components/contributions/tests/myTasksNav.test.js
+++ /dev/null
@@ -1,96 +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 { MyTasksNav, isActiveButton } from '../myTasksNav';
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-
-describe('MyTasksNav Component', () => {
- it('should display details', async () => {
- renderWithRouter(
-
-
-
-
- ,
- );
- expect(
- screen.getByRole('heading', {
- name: /my tasks/i,
- }),
- ).toBeInTheDocument();
- expect(screen.getByRole('combobox')).toBeInTheDocument();
- expect(
- screen.getByRole('button', {
- name: /sort by/i,
- }),
- ).toBeInTheDocument();
- ['All', 'Mapped', 'Validated', 'More mapping needed', 'Archived projects'].forEach((menuItem) =>
- expect(
- screen.getByRole('link', {
- name: menuItem,
- }),
- ).toBeInTheDocument(),
- );
- });
-
- // The onChange event for the react-select executes, which
- // updates the query params, but the component doesn't rerender
- // with the updated values in the test, which would have caused
- // the 'Clear filters' button to appear. Uncomment to try
-
- // it('should display clear filters button when some filter is applied', async () => {
- // // The onChange event for the react-select executes, which
- // // updates the query params, but the component doesn't rerender
- // // with the updated values in the test, which would have caused
- // // the 'Clear filters' button to appear. Uncomment to try
- // renderWithRouter(
- //
- //
- //
- //
- // ,
- // );
-
- // await selectEvent.select(screen.getByRole('combobox'), '#8629');
- // await waitFor(() => expect(screen.getByText('Clear filters')).toBeInTheDocument());
- // });
-});
-
-describe('isActiveButton', () => {
- const defaultQuery = {
- status: undefined,
- minDate: undefined,
- maxDate: undefined,
- projectStatus: undefined,
- page: undefined,
- orderBy: undefined,
- projectId: undefined,
- };
- const isActiveValue = 'bg-blue-grey white fw5';
- const isNotActiveValue = 'bg-white blue-grey';
-
- it('should display button as active ', () => {
- expect(isActiveButton('All', defaultQuery)).toBe(isActiveValue);
- expect(isActiveButton('MAPPED', { ...defaultQuery, status: ['MAPPED'] })).toBe(isActiveValue);
- expect(isActiveButton('VALIDATED', { ...defaultQuery, status: ['VALIDATED'] })).toBe(
- isActiveValue,
- );
- expect(isActiveButton('ARCHIVED', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
- isActiveValue,
- );
- });
-
- it('should display button as inactive', () => {
- expect(isActiveButton('All', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
- isNotActiveValue,
- );
- expect(isActiveButton('MAPPED', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
- isNotActiveValue,
- );
- expect(isActiveButton('MAPPED', { ...defaultQuery, projectStatus: 'VALIDATED' })).toBe(
- isNotActiveValue,
- );
- });
-});
diff --git a/frontend/src/components/contributions/tests/myTasksNav.test.tsx b/frontend/src/components/contributions/tests/myTasksNav.test.tsx
new file mode 100644
index 0000000000..96d509acda
--- /dev/null
+++ b/frontend/src/components/contributions/tests/myTasksNav.test.tsx
@@ -0,0 +1,96 @@
+
+import { screen } from '@testing-library/react';
+import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
+import { QueryParamProvider } from 'use-query-params';
+
+import { MyTasksNav, isActiveButton } from '../myTasksNav';
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+
+describe('MyTasksNav Component', () => {
+ it('should display details', async () => {
+ renderWithRouter(
+
+
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('heading', {
+ name: /my tasks/i,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: /sort by/i,
+ }),
+ ).toBeInTheDocument();
+ ['All', 'Mapped', 'Validated', 'More mapping needed', 'Archived projects'].forEach((menuItem) =>
+ expect(
+ screen.getByRole('link', {
+ name: menuItem,
+ }),
+ ).toBeInTheDocument(),
+ );
+ });
+
+ // The onChange event for the react-select executes, which
+ // updates the query params, but the component doesn't rerender
+ // with the updated values in the test, which would have caused
+ // the 'Clear filters' button to appear. Uncomment to try
+
+ // it('should display clear filters button when some filter is applied', async () => {
+ // // The onChange event for the react-select executes, which
+ // // updates the query params, but the component doesn't rerender
+ // // with the updated values in the test, which would have caused
+ // // the 'Clear filters' button to appear. Uncomment to try
+ // renderWithRouter(
+ //
+ //
+ //
+ //
+ // ,
+ // );
+
+ // await selectEvent.select(screen.getByRole('combobox'), '#8629');
+ // await waitFor(() => expect(screen.getByText('Clear filters')).toBeInTheDocument());
+ // });
+});
+
+describe('isActiveButton', () => {
+ const defaultQuery = {
+ status: undefined,
+ minDate: undefined,
+ maxDate: undefined,
+ projectStatus: undefined,
+ page: undefined,
+ orderBy: undefined,
+ projectId: undefined,
+ };
+ const isActiveValue = 'bg-blue-grey white fw5';
+ const isNotActiveValue = 'bg-white blue-grey';
+
+ it('should display button as active ', () => {
+ expect(isActiveButton('All', defaultQuery)).toBe(isActiveValue);
+ expect(isActiveButton('MAPPED', { ...defaultQuery, status: ['MAPPED'] })).toBe(isActiveValue);
+ expect(isActiveButton('VALIDATED', { ...defaultQuery, status: ['VALIDATED'] })).toBe(
+ isActiveValue,
+ );
+ expect(isActiveButton('ARCHIVED', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
+ isActiveValue,
+ );
+ });
+
+ it('should display button as inactive', () => {
+ expect(isActiveButton('All', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
+ isNotActiveValue,
+ );
+ expect(isActiveButton('MAPPED', { ...defaultQuery, projectStatus: 'ARCHIVED' })).toBe(
+ isNotActiveValue,
+ );
+ expect(isActiveButton('MAPPED', { ...defaultQuery, projectStatus: 'VALIDATED' })).toBe(
+ isNotActiveValue,
+ );
+ });
+});
diff --git a/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.js b/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.js
deleted file mode 100644
index f3b3556f23..0000000000
--- a/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import MyTasksOrderDropdown from '../myTasksOrderDropdown';
-import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-
-describe('MyTasksOrderDropdown', () => {
- const setQueryMock = jest.fn();
- const setup = async () => {
- const { user } = renderWithRouter(
-
-
- ,
- );
- const dropdownBtn = screen.getByRole('button', {
- name: /sort by/i,
- });
- await user.click(dropdownBtn);
- return { user };
- };
-
- it('displays dropdown options after button is clicked', async () => {
- await setup();
- expect(screen.getByText(/recently edited/i)).toBeInTheDocument();
- expect(screen.getByText(/project id/i)).toBeInTheDocument();
- });
-
- it('should set query when an option is selected', async () => {
- const { user } = await setup();
- await user.click(screen.getByText(/recently edited/i));
- expect(setQueryMock).toHaveBeenCalled();
- });
-
- it('should preselect option if the query matches', async () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(/project id/i)).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.tsx b/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.tsx
new file mode 100644
index 0000000000..e1974c649c
--- /dev/null
+++ b/frontend/src/components/contributions/tests/myTasksOrderDropdown.test.tsx
@@ -0,0 +1,56 @@
+import { screen } from '@testing-library/react';
+import MyTasksOrderDropdown from '../myTasksOrderDropdown';
+import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+
+describe('MyTasksOrderDropdown', () => {
+ const setQueryMock = vi.fn();
+ const setup = async () => {
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ const dropdownBtn = screen.getByRole('button', {
+ name: /sort by/i,
+ });
+ await user.click(dropdownBtn);
+ return { user };
+ };
+
+ it('displays dropdown options after button is clicked', async () => {
+ await setup();
+ expect(await screen.findByText(/recently edited/i)).toBeInTheDocument();
+ expect(await screen.findByText(/project id/i)).toBeInTheDocument();
+ });
+
+ it('should set query when an option is selected', async () => {
+ const { user } = await setup();
+ await user.click(screen.getByText(/recently edited/i));
+ expect(setQueryMock).toHaveBeenCalled();
+ });
+
+ it('should preselect option if the query matches', async () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(await screen.findByText(/project id/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/contributions/tests/taskCard.test.js b/frontend/src/components/contributions/tests/taskCard.test.js
deleted file mode 100644
index cc3f3de1d7..0000000000
--- a/frontend/src/components/contributions/tests/taskCard.test.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { TaskCard } from '../taskCard';
-
-describe('TaskCard', () => {
- it('on MAPPED state with comments', async () => {
- const { user, container } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Task #987 · Project #4321')).toBeInTheDocument();
- expect(screen.getByText(/Last updated/)).toBeInTheDocument();
- expect(screen.getByText('Ready for validation')).toBeInTheDocument();
- expect(screen.getByText('5')).toBeInTheDocument();
- expect(container.querySelectorAll('svg').length).toBe(2);
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- // hovering on the card should not change anything
- await user.hover(screen.getByText('Ready for validation'));
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- });
-
- it('on VALIDATED state without comments', async () => {
- const { user, container } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Finished')).toBeInTheDocument();
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- expect(screen.queryByText('0')).not.toBeInTheDocument();
- expect(container.querySelectorAll('svg').length).toBe(1);
- // hovering on the card should not change anything
- await user.hover(screen.getByText('Finished'));
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- });
-
- it('on BADIMAGERY state', async () => {
- const { user } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Unavailable')).toBeInTheDocument();
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- // hovering on the card should not change anything
- await user.hover(screen.getByText('Unavailable'));
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- });
-
- it('on LOCKED_FOR_VALIDATION state', async () => {
- const { user, container } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Locked for validation by user_1')).toBeInTheDocument();
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- // hovering on the card should show the resume task button
- await user.hover(screen.getByText('Locked for validation by user_1'));
- expect(screen.getByText('Resume task')).toBeInTheDocument();
- expect(container.querySelectorAll('a')[1].href).toContain('/projects/4321/tasks?search=987');
- });
-
- it('on INVALIDATED state', async () => {
- const { user } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('More mapping needed')).toBeInTheDocument();
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- // hovering on the card should show the resume task button
- await user.hover(screen.getByText('More mapping needed'));
- expect(screen.getByText('Resume task')).toBeInTheDocument();
- });
-
- it('on READY state', async () => {
- const { user, container } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText('Task #543 · Project #9983')).toBeInTheDocument();
- expect(screen.getByText(/Last updated/)).toBeInTheDocument();
- expect(screen.getByText('Available for mapping')).toBeInTheDocument();
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- // hover on the card
- await user.hover(screen.getByText('Available for mapping'));
- expect(screen.getByText('Resume task')).toBeInTheDocument();
- expect(screen.getByText('Resume task').className).toBe(
- 'dn dib-l link pv2 ph3 mh3 mv1 bg-red white f7 fr',
- );
- expect(container.querySelectorAll('a')[1].href).toContain('/projects/9983/tasks?search=543');
- // unhover
- await user.unhover(screen.getByText('Available for mapping'));
- expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/contributions/tests/taskCard.test.jsx b/frontend/src/components/contributions/tests/taskCard.test.jsx
new file mode 100644
index 0000000000..f114697cdd
--- /dev/null
+++ b/frontend/src/components/contributions/tests/taskCard.test.jsx
@@ -0,0 +1,145 @@
+import { screen } from '@testing-library/react';
+
+
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { TaskCard } from '../taskCard';
+
+describe('TaskCard', () => {
+ it('on MAPPED state with comments', async () => {
+ const { user, container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Task #987 · Project #4321')).toBeInTheDocument();
+ expect(screen.getByText(/Last updated/)).toBeInTheDocument();
+ expect(screen.getByText('Ready for validation')).toBeInTheDocument();
+ expect(screen.getByText('5')).toBeInTheDocument();
+ expect(container.querySelectorAll('svg').length).toBe(2);
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ // hovering on the card should not change anything
+ await user.hover(screen.getByText('Ready for validation'));
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ });
+
+ it('on VALIDATED state without comments', async () => {
+ const { user, container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Finished')).toBeInTheDocument();
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ expect(screen.queryByText('0')).not.toBeInTheDocument();
+ expect(container.querySelectorAll('svg').length).toBe(1);
+ // hovering on the card should not change anything
+ await user.hover(screen.getByText('Finished'));
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ });
+
+ it('on BADIMAGERY state', async () => {
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Unavailable')).toBeInTheDocument();
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ // hovering on the card should not change anything
+ await user.hover(screen.getByText('Unavailable'));
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ });
+
+ it('on LOCKED_FOR_VALIDATION state', async () => {
+ const { user, container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Locked for validation by user_1')).toBeInTheDocument();
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ // hovering on the card should show the resume task button
+ await user.hover(screen.getByText('Locked for validation by user_1'));
+ expect(screen.getByText('Resume task')).toBeInTheDocument();
+ expect(container.querySelectorAll('a')[1].href).toContain('/projects/4321/tasks?search=987');
+ });
+
+ it('on INVALIDATED state', async () => {
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('More mapping needed')).toBeInTheDocument();
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ // hovering on the card should show the resume task button
+ await user.hover(screen.getByText('More mapping needed'));
+ expect(screen.getByText('Resume task')).toBeInTheDocument();
+ });
+
+ it('on READY state', async () => {
+ const { user, container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText('Task #543 · Project #9983')).toBeInTheDocument();
+ expect(screen.getByText(/Last updated/)).toBeInTheDocument();
+ expect(screen.getByText('Available for mapping')).toBeInTheDocument();
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ // hover on the card
+ await user.hover(screen.getByText('Available for mapping'));
+ expect(screen.getByText('Resume task')).toBeInTheDocument();
+ expect(screen.getByText('Resume task').className).toBe(
+ 'dn dib-l link pv2 ph3 mh3 mv1 bg-red white f7 fr',
+ );
+ expect(container.querySelectorAll('a')[1].href).toContain('/projects/9983/tasks?search=543');
+ // unhover
+ await user.unhover(screen.getByText('Available for mapping'));
+ expect(screen.queryByText('Resume task')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/contributions/tests/taskResults.test.js b/frontend/src/components/contributions/tests/taskResults.test.js
deleted file mode 100644
index 973944dd31..0000000000
--- a/frontend/src/components/contributions/tests/taskResults.test.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import { userTasks } from '../../../network/tests/mockData/tasksStats';
-import { TaskResults, TaskCards } from '../taskResults';
-import messages from '../messages';
-
-describe('Task Results Component', () => {
- it('should display loading indicator when tasks are loading', () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(container.getElementsByClassName('show-loading-animation')[0]).toBeInTheDocument();
- });
-
- it('should prompt user to retry on failure to fetch tasks', async () => {
- const retryFnMock = jest.fn();
- const { user } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(messages.errorLoadingTasks.defaultMessage)).toBeInTheDocument();
- const retryBtn = screen.getByRole('button', {
- name: messages.retry.defaultMessage,
- });
- expect(retryBtn).toBeInTheDocument();
- await user.click(retryBtn);
- expect(retryFnMock).toHaveBeenCalled();
- });
-
- it('should display pagination details', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(`Showing 10 of 4,476`)).toBeInTheDocument();
- });
-
- it('should display fetched tasks', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getAllByRole('article').length).toBe(userTasks.tasks.length);
- expect(
- screen.getByRole('heading', {
- name: `Task #${userTasks.tasks[0].taskId} · Project #${userTasks.tasks[0].projectId}`,
- }),
- ).toBeInTheDocument();
- });
-});
-
-describe('TaskCards Component', () => {
- it('should display no contributions text if user has no tasks available', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(messages.noContributed.defaultMessage)).toBeInTheDocument();
- });
-
- it('should display passed page of tasks into TaskCard', () => {
- renderWithRouter(
-
-
- ,
- );
- expect(screen.getAllByRole('article').length).toBe(userTasks.tasks.length);
- });
-});
diff --git a/frontend/src/components/contributions/tests/taskResults.test.jsx b/frontend/src/components/contributions/tests/taskResults.test.jsx
new file mode 100644
index 0000000000..880db9bb4f
--- /dev/null
+++ b/frontend/src/components/contributions/tests/taskResults.test.jsx
@@ -0,0 +1,75 @@
+import { screen } from '@testing-library/react';
+import { IntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import { userTasks } from '../../../network/tests/mockData/tasksStats';
+import { TaskResults, TaskCards } from '../taskResults';
+import messages from '../messages';
+
+describe('Task Results Component', () => {
+ it('should display loading indicator when tasks are loading', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(container.getElementsByClassName('show-loading-animation')[0]).toBeInTheDocument();
+ });
+
+ it('should prompt user to retry on failure to fetch tasks', async () => {
+ const retryFnMock = vi.fn();
+ const { user } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText(messages.errorLoadingTasks.defaultMessage)).toBeInTheDocument();
+ const retryBtn = screen.getByRole('button', {
+ name: messages.retry.defaultMessage,
+ });
+ expect(retryBtn).toBeInTheDocument();
+ await user.click(retryBtn);
+ expect(retryFnMock).toHaveBeenCalled();
+ });
+
+ it('should display pagination details', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText(`Showing 10 of 4,476`)).toBeInTheDocument();
+ });
+
+ it('should display fetched tasks', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getAllByRole('article').length).toBe(userTasks.tasks.length);
+ expect(
+ screen.getByRole('heading', {
+ name: `Task #${userTasks.tasks[0].taskId} · Project #${userTasks.tasks[0].projectId}`,
+ }),
+ ).toBeInTheDocument();
+ });
+});
+
+describe('TaskCards Component', () => {
+ it('should display no contributions text if user has no tasks available', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText(messages.noContributed.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('should display passed page of tasks into TaskCard', () => {
+ renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getAllByRole('article').length).toBe(userTasks.tasks.length);
+ });
+});
diff --git a/frontend/src/components/deleteModal/index.js b/frontend/src/components/deleteModal/index.js
deleted file mode 100644
index cd36fefea9..0000000000
--- a/frontend/src/components/deleteModal/index.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { forwardRef, useState, useRef } from 'react';
-import { useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-import Popup from 'reactjs-popup';
-
-import messages from './messages';
-import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
-import { DeleteButton } from '../teamsAndOrgs/management';
-import { Button } from '../button';
-import { AlertIcon } from '../svgIcons';
-
-const DeleteTrigger = forwardRef((props, ref) => );
-
-export function DeleteModal({ id, name, type, className, endpointURL, onDelete }: Object) {
- const navigate = useNavigate();
- const modalRef = useRef();
- const token = useSelector((state) => state.auth.token);
- const [deleteStatus, setDeleteStatus] = useState(null);
- const [error, setErrorMessage] = useState(null);
-
- const deleteURL = endpointURL ? endpointURL : `${type}/${id}/`;
-
- const deleteEntity = () => {
- setDeleteStatus('started');
- fetchLocalJSONAPI(deleteURL, token, 'DELETE')
- .then((success) => {
- setDeleteStatus('success');
- if (type === 'notifications') {
- setTimeout(() => navigate(`/inbox`), 750);
- } else if (type === 'comments') {
- setTimeout(() => {
- onDelete();
- modalRef.current.close();
- }, 750);
- return;
- } else {
- setTimeout(() => navigate(`/manage/${type !== 'interests' ? type : 'categories'}`), 750);
- }
- })
- .catch((e) => {
- setDeleteStatus('failure');
- setErrorMessage(e.message);
- });
- };
-
- return (
-
- }
- modal
- closeOnDocumentClick
- nested
- onClose={() => {
- setDeleteStatus(null);
- setErrorMessage(null);
- }}
- >
- {(close) => (
- {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
-
-
- {!deleteStatus && (
- <>
-
-
-
-
-
-
-
- >
- )}
- {deleteStatus && (
-
- {deleteStatus === 'started' && (
- <>
-
- …
- >
- )}
- {deleteStatus === 'success' && (
-
- )}
- {deleteStatus === 'failure' && (
-
- )}
-
- )}
- {deleteStatus === 'failure' && (
-
- {(error && messages[`${error}Error`] && (
-
- )) ||
- error}
-
- )}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/components/deleteModal/index.jsx b/frontend/src/components/deleteModal/index.jsx
new file mode 100644
index 0000000000..bd2fcf1496
--- /dev/null
+++ b/frontend/src/components/deleteModal/index.jsx
@@ -0,0 +1,123 @@
+import { forwardRef, useState, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import Popup from 'reactjs-popup';
+
+import messages from './messages';
+import { fetchLocalJSONAPI } from '../../network/genericJSONRequest';
+import { DeleteButton } from '../teamsAndOrgs/management';
+import { Button } from '../button';
+import { AlertIcon } from '../svgIcons';
+
+const DeleteTrigger = forwardRef((props, ref) => );
+
+export function DeleteModal({ id, name, type, className, endpointURL, onDelete }) {
+ const navigate = useNavigate();
+ const modalRef = useRef();
+ const token = useSelector((state) => state.auth.token);
+ const [deleteStatus, setDeleteStatus] = useState(null);
+ const [error, setErrorMessage] = useState(null);
+
+ const deleteURL = endpointURL ? endpointURL : `${type}/${id}/`;
+
+ const deleteEntity = () => {
+ setDeleteStatus('started');
+ fetchLocalJSONAPI(deleteURL, token, 'DELETE')
+ .then((success) => {
+ setDeleteStatus('success');
+ if (type === 'notifications') {
+ setTimeout(() => navigate(`/inbox`), 750);
+ } else if (type === 'comments') {
+ setTimeout(() => {
+ onDelete();
+ modalRef.current.close();
+ }, 750);
+ return;
+ } else {
+ setTimeout(() => navigate(`/manage/${type !== 'interests' ? type : 'categories'}`), 750);
+ }
+ })
+ .catch((e) => {
+ setDeleteStatus('failure');
+ setErrorMessage(e.message);
+ });
+ };
+
+ return (
+
+ }
+ modal
+ closeOnDocumentClick
+ nested
+ onClose={() => {
+ setDeleteStatus(null);
+ setErrorMessage(null);
+ }}
+ >
+ {(close) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+
+
+ {!deleteStatus && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+ {deleteStatus && (
+
+ {deleteStatus === 'started' && (
+ <>
+
+ …
+ >
+ )}
+ {deleteStatus === 'success' && (
+
+ )}
+ {deleteStatus === 'failure' && (
+
+ )}
+
+ )}
+ {deleteStatus === 'failure' && (
+
+ {(error && messages[`${error}Error`] && (
+
+ )) ||
+ error}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/deleteModal/messages.js b/frontend/src/components/deleteModal/messages.ts
similarity index 100%
rename from frontend/src/components/deleteModal/messages.js
rename to frontend/src/components/deleteModal/messages.ts
diff --git a/frontend/src/components/deleteModal/tests/index.test.js b/frontend/src/components/deleteModal/tests/index.test.js
deleted file mode 100644
index 7720764831..0000000000
--- a/frontend/src/components/deleteModal/tests/index.test.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import { screen, waitFor, within } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import {
- createComponentWithMemoryRouter,
- ReduxIntlProviders,
- renderWithRouter,
-} from '../../../utils/testWithIntl';
-import { interest } from '../../../network/tests/mockData/management';
-import { DeleteModal } from '../index';
-
-describe('Delete Interest', () => {
- const setup = () => {
- const { user, history } = renderWithRouter(
-
-
- ,
- );
- const deleteButton = screen.getByRole('button', {
- name: 'Delete',
- });
-
- return {
- user,
- deleteButton,
- history,
- };
- };
-
- it('should ask for confirmation when user tries to delete an interest', async () => {
- const { user, deleteButton } = setup();
- await user.click(deleteButton);
- expect(screen.getByText('Are you sure you want to delete this category?')).toBeInTheDocument();
- });
-
- it('should close the confirmation popup when cancel is clicked', async () => {
- const { user, deleteButton } = setup();
- await user.click(deleteButton);
- const cancelButton = screen.getByRole('button', {
- name: /cancel/i,
- });
- await user.click(cancelButton);
- expect(
- screen.queryByRole('heading', {
- name: 'Are you sure you want to delete this category?',
- }),
- ).not.toBeInTheDocument();
- });
-
- it('should direct to passed type list page on successful deletion of an interest', async () => {
- const { user, router } = createComponentWithMemoryRouter(
-
-
- ,
- );
-
- const deleteButton = screen.getByRole('button', {
- name: 'Delete',
- });
-
- await user.click(deleteButton);
- const dialog = screen.getByRole('dialog');
- const deleteConfirmationButton = within(dialog).getByRole('button', {
- name: /delete/i,
- });
- await user.click(deleteConfirmationButton);
- expect(
- await screen.findByRole('heading', { name: /campaign deleted successfully./i }),
- ).toBeInTheDocument();
- await waitFor(() => expect(router.state.location.pathname).toBe('/manage/campaigns'));
- });
-
- it('should direct to categories list page on successful deletion of an interest', async () => {
- const { user, router } = createComponentWithMemoryRouter(
-
-
- ,
- );
-
- const deleteButton = screen.getByRole('button', {
- name: 'Delete',
- });
-
- await user.click(deleteButton);
- const dialog = screen.getByRole('dialog');
- const deleteConfirmationButton = within(dialog).getByRole('button', {
- name: /delete/i,
- });
- await user.click(deleteConfirmationButton);
- expect(
- await screen.findByRole('heading', { name: /interest deleted successfully./i }),
- ).toBeInTheDocument();
- await waitFor(() => expect(router.state.location.pathname).toBe('/manage/categories'));
- });
-});
diff --git a/frontend/src/components/deleteModal/tests/index.test.tsx b/frontend/src/components/deleteModal/tests/index.test.tsx
new file mode 100644
index 0000000000..08c3c55b2d
--- /dev/null
+++ b/frontend/src/components/deleteModal/tests/index.test.tsx
@@ -0,0 +1,95 @@
+import { screen, waitFor, within } from '@testing-library/react';
+
+
+import {
+ createComponentWithMemoryRouter,
+ ReduxIntlProviders,
+ renderWithRouter,
+} from '../../../utils/testWithIntl';
+import { interest } from '../../../network/tests/mockData/management';
+import { DeleteModal } from '../index';
+
+describe('Delete Interest', () => {
+ const setup = () => {
+ const { user, history } = renderWithRouter(
+
+
+ ,
+ );
+ const deleteButton = screen.getByRole('button', {
+ name: 'Delete',
+ });
+
+ return {
+ user,
+ deleteButton,
+ history,
+ };
+ };
+
+ it('should ask for confirmation when user tries to delete an interest', async () => {
+ const { user, deleteButton } = setup();
+ await user.click(deleteButton);
+ expect(screen.getByText('Are you sure you want to delete this category?')).toBeInTheDocument();
+ });
+
+ it('should close the confirmation popup when cancel is clicked', async () => {
+ const { user, deleteButton } = setup();
+ await user.click(deleteButton);
+ const cancelButton = screen.getByRole('button', {
+ name: /cancel/i,
+ });
+ await user.click(cancelButton);
+ expect(
+ screen.queryByRole('heading', {
+ name: 'Are you sure you want to delete this category?',
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should direct to passed type list page on successful deletion of an interest', async () => {
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+ ,
+ );
+
+ const deleteButton = screen.getByRole('button', {
+ name: 'Delete',
+ });
+
+ await user.click(deleteButton);
+ const dialog = screen.getByRole('dialog');
+ const deleteConfirmationButton = within(dialog).getByRole('button', {
+ name: /delete/i,
+ });
+ await user.click(deleteConfirmationButton);
+ expect(
+ await screen.findByRole('heading', { name: /campaign deleted successfully./i }),
+ ).toBeInTheDocument();
+ await waitFor(() => expect(router.state.location.pathname).toBe('/manage/campaigns'));
+ });
+
+ it('should direct to categories list page on successful deletion of an interest', async () => {
+ const { user, router } = createComponentWithMemoryRouter(
+
+
+ ,
+ );
+
+ const deleteButton = screen.getByRole('button', {
+ name: 'Delete',
+ });
+
+ await user.click(deleteButton);
+ const dialog = screen.getByRole('dialog');
+ const deleteConfirmationButton = within(dialog).getByRole('button', {
+ name: /delete/i,
+ });
+ await user.click(deleteConfirmationButton);
+ expect(
+ await screen.findByRole('heading', { name: /interest deleted successfully./i }),
+ ).toBeInTheDocument();
+ await waitFor(() => expect(router.state.location.pathname).toBe('/manage/categories'));
+ });
+});
diff --git a/frontend/src/components/dropdown.js b/frontend/src/components/dropdown.js
deleted file mode 100644
index 45a4c61fe5..0000000000
--- a/frontend/src/components/dropdown.js
+++ /dev/null
@@ -1,180 +0,0 @@
-import { createRef, forwardRef, useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { ChevronDownIcon, CheckIcon } from './svgIcons';
-import { CustomButton } from './button';
-
-const DropdownContent = forwardRef((props, ref) => {
- const navigate = useNavigate();
- const isActive = (obj) => {
- return props.value === obj.value;
- };
-
- const handleClick = (data) => {
- if (data) {
- const label = data.label;
- if (!props.value || !props.onChange) {
- if (!label) return;
- if (data.href && data.internalLink) {
- navigate(data.href);
- }
- return;
- }
- const value = props.value;
- let ourObj = data;
- if (!ourObj) return;
-
- let isRemove = false;
- for (let x = 0; x < value.length; x++) {
- if (value[x].label === label) {
- isRemove = true;
- props.onRemove && props.onRemove(ourObj);
- props.onChange(value.slice(0, x).concat(value.slice(x + 1)));
- }
- }
-
- if (!isRemove) {
- let newArray = value.slice(0, value.length);
- if (!props.multi) {
- newArray = [];
- }
- newArray.push(ourObj);
- props.onAdd && props.onAdd(ourObj);
- props.onChange(newArray);
- }
- }
- if (!props.multi) {
- props.toggleDropdown();
- }
- };
-
- return (
-
- );
-});
-
-export function Dropdown(props) {
- const [display, setDisplay] = useState(false);
-
- const contentRef = createRef();
- const buttonRef = createRef();
-
- useEffect(() => {
- const handleClickOutside = (event) => {
- if (
- !contentRef.current ||
- contentRef.current.contains(event.target) ||
- !buttonRef.current ||
- buttonRef.current.contains(event.target)
- ) {
- return;
- }
- setDisplay(false);
- };
- document.addEventListener('mousedown', handleClickOutside);
- document.addEventListener('touchstart', handleClickOutside);
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- document.removeEventListener('touchstart', handleClickOutside);
- };
- }, [contentRef, buttonRef]);
-
- const toggleDropdown = () => {
- setDisplay(!display);
- };
-
- const getActiveOrDisplay = () => {
- const activeItems = props.options.filter(
- (item) => item.label === props.value || item.value === props.value,
- );
- return activeItems.length === 0 || activeItems.length > 1
- ? props.display
- : activeItems[0].label;
- };
-
- return (
-
-
- {getActiveOrDisplay()}
-
-
-
- {display && (
-
- )}
-
- );
-}
diff --git a/frontend/src/components/dropdown.tsx b/frontend/src/components/dropdown.tsx
new file mode 100644
index 0000000000..01dee971d9
--- /dev/null
+++ b/frontend/src/components/dropdown.tsx
@@ -0,0 +1,216 @@
+import { createRef, forwardRef, useEffect, useState, RefObject } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ChevronDownIcon, CheckIcon } from './svgIcons';
+import { CustomButton } from './button';
+
+interface DropdownOption {
+ label: string;
+ value: string;
+ href?: string;
+ internalLink?: boolean;
+}
+
+interface DropdownContentProps {
+ value: DropdownOption | DropdownOption[];
+ onChange?: (value: DropdownOption[]) => void;
+ onRemove?: (option: DropdownOption) => void;
+ onAdd?: (option: DropdownOption) => void;
+ toggleDropdown: () => void;
+ multi?: boolean;
+ toTop?: boolean;
+ options: DropdownOption[];
+ deletable?: (value: string) => void;
+}
+
+const DropdownContent = forwardRef((props, ref) => {
+ const navigate = useNavigate();
+
+ const isActive = (obj: DropdownOption): boolean => {
+ return Array.isArray(props.value)
+ ? props.value.some(item => item.value === obj.value)
+ : props.value.value === obj.value;
+ };
+
+ const handleClick = (data: DropdownOption) => {
+ if (data) {
+ const label = data.label;
+ if (!props.value || !props.onChange) {
+ if (!label) return;
+ if (data.href && data.internalLink) {
+ navigate(data.href);
+ }
+ return;
+ }
+ const value = props.value;
+ let ourObj = data;
+ if (!ourObj) return;
+
+ let isRemove = false;
+ for (let x = 0; x < value.length; x++) {
+ if (value[x].label === label) {
+ isRemove = true;
+ props.onRemove && props.onRemove(ourObj);
+ props.onChange(value.slice(0, x).concat(value.slice(x + 1)));
+ }
+ }
+
+ if (!isRemove) {
+ let newArray = value.slice(0, value.length);
+ if (!props.multi) {
+ newArray = [];
+ }
+ newArray.push(ourObj);
+ props.onAdd && props.onAdd(ourObj);
+ props.onChange(newArray);
+ }
+ }
+ if (!props.multi) {
+ props.toggleDropdown();
+ }
+ };
+
+ return (
+
+ );
+});
+
+interface DropdownProps {
+ options: DropdownOption[];
+ value: DropdownOption | DropdownOption[]; // Changed here
+ display: React.ReactNode;
+ className?: string;
+ toTop?: boolean;
+ multi?: boolean;
+ onChange?: (value: DropdownOption[]) => void;
+ onRemove?: (option: DropdownOption) => void;
+ onAdd?: (option: DropdownOption) => void;
+ deletable?: (value: string) => void;
+}
+
+export function Dropdown(props: DropdownProps) {
+ const [display, setDisplay] = useState(false);
+
+ const contentRef: RefObject = createRef();
+ const buttonRef: RefObject = createRef();
+
+ useEffect(() => {
+ const handleClickOutside = (event: Event) => {
+ if (
+ !contentRef.current ||
+ contentRef.current.contains(event.target as Node) ||
+ !buttonRef.current ||
+ buttonRef.current.contains(event.target as Node)
+ ) {
+ return;
+ }
+ setDisplay(false);
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('touchstart', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('touchstart', handleClickOutside);
+ };
+ }, [contentRef, buttonRef]);
+
+ const toggleDropdown = () => {
+ setDisplay(!display);
+ };
+
+ const getActiveOrDisplay = () => {
+ const activeItems = props.options.filter(
+ (item) => item.label === props.value || item.value === props.value,
+ );
+ return activeItems.length === 0 || activeItems.length > 1
+ ? props.display
+ : activeItems[0].label;
+ };
+
+ return (
+
+
+ {getActiveOrDisplay()}
+
+
+
+ {display && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/editor.js b/frontend/src/components/editor.jsx
similarity index 100%
rename from frontend/src/components/editor.js
rename to frontend/src/components/editor.jsx
diff --git a/frontend/src/components/footer/index.js b/frontend/src/components/footer/index.js
deleted file mode 100644
index e91613ee09..0000000000
--- a/frontend/src/components/footer/index.js
+++ /dev/null
@@ -1,160 +0,0 @@
-import { Fragment } from 'react';
-import { useSelector } from 'react-redux';
-import { Link, matchRoutes, useLocation } from 'react-router-dom';
-import { FormattedMessage } from 'react-intl';
-import {
- TwitterIcon,
- FacebookIcon,
- YoutubeIcon,
- GithubIcon,
- InstagramIcon,
- ExternalLinkIcon,
-} from '../svgIcons';
-import messages from '../messages';
-import { getMenuItemsForUser } from '../header';
-import {
- ORG_TWITTER,
- ORG_GITHUB,
- ORG_INSTAGRAM,
- ORG_FB,
- ORG_YOUTUBE,
- ORG_PRIVACY_POLICY_URL,
-} from '../../config';
-import './styles.scss';
-
-const socialNetworks = [
- { link: ORG_TWITTER, icon: },
- { link: ORG_FB, icon: },
- { link: ORG_YOUTUBE, icon: },
- { link: ORG_INSTAGRAM, icon: },
- { link: ORG_GITHUB, icon: },
-];
-
-export function Footer() {
- const location = useLocation();
- const userDetails = useSelector((state) => state.auth.userDetails);
-
- const footerDisabledPaths = [
- 'projects/:id/tasks',
- 'projects/:id/instructions',
- 'projects/:id/contributions',
- 'projects/:id/map',
- 'projects/:id/validate',
- 'projects/:id/live',
- 'manage/organisations/new/',
- 'manage/teams/new',
- 'manage/campaigns/new',
- 'manage/projects/new',
- 'manage/categories/new',
- 'manage/licenses/new',
- 'manage/partners/new',
- 'teams/:id/membership',
- '/api-docs/',
- ];
-
- const matchedRoute = matchRoutes(
- footerDisabledPaths.map((path) => ({
- path,
- })),
- location,
- );
-
- if (matchedRoute) {
- return null;
- } else {
- return (
-
- );
- }
-}
diff --git a/frontend/src/components/footer/index.tsx b/frontend/src/components/footer/index.tsx
new file mode 100644
index 0000000000..f1c94164c2
--- /dev/null
+++ b/frontend/src/components/footer/index.tsx
@@ -0,0 +1,162 @@
+import { Fragment } from 'react';
+import { useSelector } from 'react-redux';
+import { Link, matchRoutes, useLocation } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import {
+ TwitterIcon,
+ FacebookIcon,
+ YoutubeIcon,
+ GithubIcon,
+ InstagramIcon,
+ ExternalLinkIcon,
+} from '../svgIcons';
+import messages from '../messages';
+import { getMenuItemsForUser } from '../header';
+import {
+ ORG_TWITTER,
+ ORG_GITHUB,
+ ORG_INSTAGRAM,
+ ORG_FB,
+ ORG_YOUTUBE,
+ ORG_PRIVACY_POLICY_URL,
+} from '../../config';
+import './styles.scss';
+import { RootStore } from '../../store';
+
+const socialNetworks = [
+ { link: ORG_TWITTER, icon: },
+ { link: ORG_FB, icon: },
+ { link: ORG_YOUTUBE, icon: },
+ { link: ORG_INSTAGRAM, icon: },
+ { link: ORG_GITHUB, icon: },
+];
+
+export function Footer() {
+ const location = useLocation();
+ const userDetails = useSelector((state: RootStore) => state.auth.userDetails);
+
+ const footerDisabledPaths = [
+ 'projects/:id/tasks',
+ 'projects/:id/instructions',
+ 'projects/:id/contributions',
+ 'projects/:id/map',
+ 'projects/:id/validate',
+ 'projects/:id/live',
+ 'manage/organisations/new/',
+ 'manage/teams/new',
+ 'manage/campaigns/new',
+ 'manage/projects/new',
+ 'manage/categories/new',
+ 'manage/licenses/new',
+ 'manage/partners/new',
+ 'teams/:id/membership',
+ '/api-docs/',
+ ];
+
+ const matchedRoute = matchRoutes(
+ footerDisabledPaths.map((path) => ({
+ path,
+ })),
+ location,
+ );
+
+ if (matchedRoute) {
+ return null;
+ } else {
+ return (
+
+ );
+ }
+}
diff --git a/frontend/src/components/footer/tests/index.test.js b/frontend/src/components/footer/tests/index.test.js
deleted file mode 100644
index f1f8951248..0000000000
--- a/frontend/src/components/footer/tests/index.test.js
+++ /dev/null
@@ -1,136 +0,0 @@
-import '@testing-library/jest-dom';
-import { screen } from '@testing-library/react';
-
-import { Footer } from '..';
-import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
-import {
- ORG_TWITTER,
- ORG_GITHUB,
- ORG_INSTAGRAM,
- ORG_FB,
- ORG_YOUTUBE,
- ORG_PRIVACY_POLICY_URL,
- SERVICE_DESK,
-} from '../../../config';
-import messages from '../../messages';
-
-describe('Footer', () => {
- it('should display component details', () => {
- const { container } = renderWithRouter(
-
-
- ,
- );
- expect(screen.getByText(messages.definition.defaultMessage)).toBeInTheDocument();
- ['Explore projects', 'Learn', 'About'].forEach((menuItem) =>
- expect(
- screen.getByRole('link', {
- name: menuItem,
- }),
- ).toBeInTheDocument(),
- );
- expect(
- screen.getByRole('img', {
- name: 'Creative Commons License',
- }),
- ).toHaveAttribute('src', 'https://i.creativecommons.org/l/by-sa/4.0/88x31.png');
- expect(
- screen.getByRole('link', {
- name: 'Creative Commons License',
- }),
- ).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
- expect(
- screen.getByRole('link', {
- name: messages.license.defaultMessage,
- }),
- ).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
- expect(
- screen.getByRole('link', {
- name: messages.credits.defaultMessage,
- }),
- ).toHaveAttribute('href', '/about');
- let socialLinksCount = 0;
- [
- {
- name: 'Twitter',
- link: ORG_TWITTER,
- },
- {
- name: 'GitHub',
- link: ORG_GITHUB,
- },
- {
- name: 'Instagram',
- link: ORG_INSTAGRAM,
- },
- {
- name: 'Facebook',
- link: ORG_FB,
- },
- {
- name: 'YouTube',
- link: ORG_YOUTUBE,
- },
- ].forEach((social) => {
- if (social.link) {
- expect(
- screen.getByRole('link', {
- name: social.name,
- }),
- ).toHaveAttribute('href', social.link);
- socialLinksCount += 1;
- } else {
- expect(
- screen.queryByRole('link', {
- name: social.name,
- }),
- ).not.toBeInTheDocument();
- }
- });
- if (ORG_PRIVACY_POLICY_URL) {
- expect(
- screen.getByRole('link', {
- name: messages.privacyPolicy.defaultMessage,
- }),
- ).toHaveAttribute('href', `${ORG_PRIVACY_POLICY_URL}`);
- } else {
- expect(
- screen.queryByRole('link', {
- name: messages.privacyPolicy.defaultMessage,
- }),
- ).not.toBeInTheDocument();
- }
- if (SERVICE_DESK) {
- expect(
- screen.getByRole('link', {
- name: 'Support',
- }),
- ).toBeInTheDocument();
- expect(container.querySelectorAll('svg').length).toBe(socialLinksCount + 1);
- } else {
- expect(
- screen.queryByRole('link', {
- name: 'Support',
- }),
- ).not.toBeInTheDocument();
- expect(container.querySelectorAll('svg').length).toBe(socialLinksCount);
- }
- expect(
- screen.getByRole('link', {
- name: messages.learn.defaultMessage,
- }),
- ).toHaveAttribute('href', 'https://osm.org/about');
- });
-
- it('should not display foooter for specified URLs', () => {
- const { container } = renderWithRouter(
-
-
- ,
- {
- route: 'manage/teams/new', // one of those links where footer is not expected
- },
- );
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/frontend/src/components/footer/tests/index.test.tsx b/frontend/src/components/footer/tests/index.test.tsx
new file mode 100644
index 0000000000..3d84b9a5f3
--- /dev/null
+++ b/frontend/src/components/footer/tests/index.test.tsx
@@ -0,0 +1,134 @@
+import { screen } from '@testing-library/react';
+import { Footer } from '..';
+import { ReduxIntlProviders, renderWithRouter } from '../../../utils/testWithIntl';
+import {
+ ORG_TWITTER,
+ ORG_GITHUB,
+ ORG_INSTAGRAM,
+ ORG_FB,
+ ORG_YOUTUBE,
+ ORG_PRIVACY_POLICY_URL,
+ SERVICE_DESK,
+} from '../../../config';
+import messages from '../../messages';
+
+describe('Footer', () => {
+ it('should display component details', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ );
+ expect(screen.getByText(messages.definition.defaultMessage)).toBeInTheDocument();
+ ['Explore projects', 'Learn', 'About'].forEach((menuItem) =>
+ expect(
+ screen.getByRole('link', {
+ name: menuItem,
+ }),
+ ).toBeInTheDocument(),
+ );
+ expect(
+ screen.getByRole('img', {
+ name: 'Creative Commons License',
+ }),
+ ).toHaveAttribute('src', 'https://i.creativecommons.org/l/by-sa/4.0/88x31.png');
+ expect(
+ screen.getByRole('link', {
+ name: 'Creative Commons License',
+ }),
+ ).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
+ expect(
+ screen.getByRole('link', {
+ name: messages.license.defaultMessage,
+ }),
+ ).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
+ expect(
+ screen.getByRole('link', {
+ name: messages.credits.defaultMessage,
+ }),
+ ).toHaveAttribute('href', '/about');
+ let socialLinksCount = 0;
+ [
+ {
+ name: 'Twitter',
+ link: ORG_TWITTER,
+ },
+ {
+ name: 'GitHub',
+ link: ORG_GITHUB,
+ },
+ {
+ name: 'Instagram',
+ link: ORG_INSTAGRAM,
+ },
+ {
+ name: 'Facebook',
+ link: ORG_FB,
+ },
+ {
+ name: 'YouTube',
+ link: ORG_YOUTUBE,
+ },
+ ].forEach((social) => {
+ if (social.link) {
+ expect(
+ screen.getByRole('link', {
+ name: social.name,
+ }),
+ ).toHaveAttribute('href', social.link);
+ socialLinksCount += 1;
+ } else {
+ expect(
+ screen.queryByRole('link', {
+ name: social.name,
+ }),
+ ).not.toBeInTheDocument();
+ }
+ });
+ if (ORG_PRIVACY_POLICY_URL) {
+ expect(
+ screen.getByRole('link', {
+ name: messages.privacyPolicy.defaultMessage,
+ }),
+ ).toHaveAttribute('href', `${ORG_PRIVACY_POLICY_URL}`);
+ } else {
+ expect(
+ screen.queryByRole('link', {
+ name: messages.privacyPolicy.defaultMessage,
+ }),
+ ).not.toBeInTheDocument();
+ }
+ if (SERVICE_DESK) {
+ expect(
+ screen.getByRole('link', {
+ name: 'Support',
+ }),
+ ).toBeInTheDocument();
+ expect(container.querySelectorAll('svg').length).toBe(socialLinksCount + 1);
+ } else {
+ expect(
+ screen.queryByRole('link', {
+ name: 'Support',
+ }),
+ ).not.toBeInTheDocument();
+ expect(container.querySelectorAll('svg').length).toBe(socialLinksCount);
+ }
+ expect(
+ screen.getByRole('link', {
+ name: messages.learn.defaultMessage,
+ }),
+ ).toHaveAttribute('href', 'https://osm.org/about');
+ });
+
+ it('should not display foooter for specified URLs', () => {
+ const { container } = renderWithRouter(
+
+
+ ,
+ {
+ route: 'manage/teams/new', // one of those links where footer is not expected
+ },
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/frontend/src/components/formInputs.js b/frontend/src/components/formInputs.js
deleted file mode 100644
index d3e8a650ab..0000000000
--- a/frontend/src/components/formInputs.js
+++ /dev/null
@@ -1,261 +0,0 @@
-import { useEffect, useState, useRef } from 'react';
-import { useSelector } from 'react-redux';
-import { Field } from 'react-final-form';
-import Select from 'react-select';
-import { FormattedMessage } from 'react-intl';
-
-import messages from './messages';
-import { formatCountryList } from '../utils/countries';
-import { fetchLocalJSONAPI } from '../network/genericJSONRequest';
-import { CheckIcon, SearchIcon, CloseIcon } from './svgIcons';
-
-export const RadioField = ({ name, value, className, required = false }: Object) => (
-
-);
-
-export const SwitchToggle = ({
- label,
- isChecked,
- onChange,
- labelPosition,
- small = false,
- isDisabled = false,
-}: Object) => (
-
- {label && labelPosition !== 'right' &&
{label}}
-
- {label && labelPosition === 'right' &&
{label}}
-
-);
-
-export const OrganisationSelect = ({ className, orgId, onChange }) => {
- const userDetails = useSelector((state) => state.auth.userDetails);
- const token = useSelector((state) => state.auth.token);
- const [organisations, setOrganisations] = useState([]);
-
- useEffect(() => {
- if (token && 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));
- }
- }, [userDetails, token]);
-
- const getOrgPlaceholder = (id) => {
- const orgs = organisations.filter((org) => org.organisationId === id);
- return orgs.length ? orgs[0].name : ;
- };
-
- return (
-
- );
-};
-
-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 (
+