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 @@ - + @@ -14,20 +14,20 @@ - - + + - + - + - %REACT_APP_ORG_CODE% Tasking Manager + %VITE_ORG_CODE% Tasking Manager
+
.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![image](${url})\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)} - > - - -
-
- )} -
- iconConfig[key])} - extraCommands={[]} - height={200} - value={comment} - onChange={setComment} - textareaProps={{ - ...getInputProps(), - spellCheck: 'true', - placeholder: useIntl().formatMessage(placeholderMsg), - ...markdownTextareaProps, - }} - defaultTabEnable - /> - - {isShowFooter && ( -
- - - - - - -
- )} -
- {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![image](${url})\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)} + > + + +
+
+ )} +
+ iconConfig[key])} + extraCommands={[]} + height={200} + value={comment} + onChange={setComment} + textareaProps={{ + ...getInputProps(), + spellCheck: 'true', + placeholder: useIntl().formatMessage(placeholderMsg), + ...markdownTextareaProps, + }} + defaultTabEnable + /> + + {isShowFooter && ( +
+ + + + + + +
+ )} +
+ {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 ( -
- `#${label}`} + noOptionsMessage={() => } + onChange={(e) => onSortSelect(e.value)} + options={options} + placeholder={} + // @ts-expect-error TS Migrations + value={options?.find(({ value }) => value === allQueryParams.projectId) || null} + /> +
+ ); +} 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) */ -
-
-
-

- -

-
-
-
-
-
- - - {!notAnyFilter && ( - - - - )} -
-
-
-
- - - - - - - - - - - - - - - -
-
- ); -}; 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) */ +
+
+
+

+ +

+
+
+
+
+
+ + + {!notAnyFilter && ( + + + + )} +
+
+
+
+ + + + + + + + + + + + + + + +
+
+ ); +}; 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 ( -
9 ? ' h5 overflow-y-scroll' : ''}`} - > - {props.options.map((i, k) => ( - - {props.multi && ( - - )} - {i.href ? ( - i.internalLink ? ( - <> - {i.label} - {isActive(i) && } - - ) : ( - - {i.label} - {isActive(i) && } - - ) - ) : ( - - {i.label} - {isActive(i) && ( - - - - )} - - )} - {props.deletable && ( - { - e.preventDefault(); - e.stopPropagation(); - props.toggleDropdown(); - props.deletable(i.value); - }} - > - x - - )} - - ))} -
- ); -}); - -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 ( +
9 ? ' h5 overflow-y-scroll' : ''}`} + > + {props.options.map((i, k) => ( + + {props.multi && ( + + )} + {i.href ? ( + i.internalLink ? ( + <> + {i.label} + {isActive(i) && } + + ) : ( + + {i.label} + {isActive(i) && } + + ) + ) : ( + + {i.label} + {isActive(i) && ( + + + + )} + + )} + {props.deletable && ( + { + e.preventDefault(); + e.stopPropagation(); + props.toggleDropdown(); + props.deletable?.(i.value); + }} + > + x + + )} + + ))} +
+ ); +}); + +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 ( -
-
-

- -

-
- {getMenuItemsForUser(userDetails).map((item) => ( - - {!item.serviceDesk ? ( - - - - ) : ( - - - - - )} - - ))} -
-
- - {/* AWS logo */} -
- - Powered by AWS Cloud Computing - -
- -
-
-
- - Creative Commons License - -
- - - -
- - - - {ORG_PRIVACY_POLICY_URL && ( -
- - - -
- )} -
-
-

- {socialNetworks - .filter((item) => item.link) - .map((item, n) => ( - - {item.icon} - - ))} -

- - - -
-
-
- ); - } -} 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 ( +
+
+

+ +

+
+ {getMenuItemsForUser(userDetails).map((item) => ( + + {/* @ts-expect-error TS migrations */} + {!item.serviceDesk ? ( + + + + ) : ( + + + + + )} + + ))} +
+
+ + {/* AWS logo */} +
+ + Powered by AWS Cloud Computing + +
+ +
+
+
+ + Creative Commons License + +
+ + + +
+ + + + {ORG_PRIVACY_POLICY_URL && ( +
+ + + +
+ )} +
+
+

+ {socialNetworks + .filter((item) => item.link) + .map((item, n) => ( + + {item.icon} + + ))} +

+ + + +
+
+
+ ); + } +} 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( - -