diff --git a/app/constants/file-uploader-size-limits.js b/app/constants/file-uploader-size-limits.js index d8028b331e..0f09a849b8 100644 --- a/app/constants/file-uploader-size-limits.js +++ b/app/constants/file-uploader-size-limits.js @@ -8,7 +8,7 @@ export const PLANNING_UNIT_UPLOADER_MAX_SIZE = 1048576; // 1MiB export const PLANNING_AREA_UPLOADER_MAX_SIZE = 1048576; // 1MiB export const PLANNING_AREA_GRID_UPLOADER_MAX_SIZE = 10485760; // 10MiB -export const PROTECTED_AREA_UPLOADER_MAX_SIZE = 10485760; // 10MiB +export const PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE = 10485760; // 10MiB export const FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE = 20971520; // 20MiB export const FEATURES_UPLOADER_CSV_MAX_SIZE = 52428800; // 50MiB export const COST_SURFACE_UPLOADER_MAX_SIZE = 20971520; // 20MiB diff --git a/app/hooks/map/index.ts b/app/hooks/map/index.ts index af12fe8619..1fbe551d07 100644 --- a/app/hooks/map/index.ts +++ b/app/hooks/map/index.ts @@ -181,7 +181,6 @@ export function useWDPAPreviewLayer({ options, }: UseWDPAPreviewLayer) { const { opacity = 1, visibility = true } = options || {}; - return useMemo(() => { if (!active || !bbox) return null; diff --git a/app/hooks/wdpa/index.ts b/app/hooks/wdpa/index.ts index eb4cb799e6..62ab528d1e 100644 --- a/app/hooks/wdpa/index.ts +++ b/app/hooks/wdpa/index.ts @@ -1,31 +1,33 @@ -import { useMemo } from 'react'; - -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery, QueryObserverOptions, useQueryClient } from 'react-query'; +import { AxiosRequestConfig } from 'axios'; import { useSession } from 'next-auth/react'; -import SCENARIOS from 'services/scenarios'; -import WDPA from 'services/wdpa'; +import { Project } from 'types/api/project'; +import { Scenario } from 'types/api/scenario'; +import { WDPA } from 'types/api/wdpa'; -import { - UseWDPACategoriesProps, - UseSaveScenarioProtectedAreasProps, - SaveScenarioProtectedAreasProps, -} from './types'; +import { API } from 'services/api'; +import SCENARIOS from 'services/scenarios'; +import UPLOADS from 'services/uploads'; export function useWDPACategories({ adminAreaId, customAreaId, scenarioId, -}: UseWDPACategoriesProps) { +}: { + adminAreaId?: WDPA['id']; + customAreaId?: WDPA['id']; + scenarioId: Scenario['id']; +}) { const { data: session } = useSession(); return useQuery( ['protected-areas', adminAreaId, customAreaId], async () => - WDPA.request({ + API.request({ method: 'GET', - url: `/${scenarioId}/protected-areas`, + url: `scenarios/${scenarioId}/protected-areas`, params: { ...(adminAreaId && { 'filter[adminAreaId]': adminAreaId, @@ -40,7 +42,6 @@ export function useWDPACategories({ }).then(({ data }) => data), { enabled: !!adminAreaId || !!customAreaId, - select: ({ data }) => data, } ); } @@ -49,10 +50,24 @@ export function useSaveScenarioProtectedAreas({ requestConfig = { method: 'POST', }, -}: UseSaveScenarioProtectedAreasProps) { +}: { + requestConfig?: AxiosRequestConfig; +}) { const { data: session } = useSession(); - const saveScenarioProtectedAreas = ({ id, data }: SaveScenarioProtectedAreasProps) => { + const saveScenarioProtectedAreas = ({ + id, + data, + }: { + id: Scenario['id']; + data: { + areas: { + id: string; + selected: boolean; + }[]; + threshold: number; + }; + }) => { return SCENARIOS.request({ url: `/${id}/protected-areas`, data, @@ -63,12 +78,80 @@ export function useSaveScenarioProtectedAreas({ }); }; - return useMutation(saveScenarioProtectedAreas, { - onSuccess: (data: any, variables, context) => { - console.info('Succces', data, variables, context); - }, - onError: (error, variables, context) => { - console.info('Error', error, variables, context); + return useMutation(saveScenarioProtectedAreas); +} + +export function useProjectWDPAs( + pid: Project['id'], + params: { search?: string; sort?: string; filters?: Record } = {}, + queryOptions: QueryObserverOptions = {} +) { + const { data: session } = useSession(); + + return useQuery({ + queryKey: ['wdpas', pid], + queryFn: async () => + API.request<{ data: WDPA[] }>({ + method: 'GET', + url: `/projects/${pid}/protected-areas`, + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + params, + }).then(({ data }) => data.data), + enabled: Boolean(pid), + ...queryOptions, + }); +} + +export function useEditWDPA({ + requestConfig = { + method: 'PATCH', + }, +}) { + const { data: session } = useSession(); + + const saveProjectWDPA = ({ wdpaId, data }: { wdpaId: string; data: { name: string } }) => { + return API.request({ + method: 'PATCH', + url: `/protected-areas/${wdpaId}`, + data, + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + ...requestConfig, + }); + }; + + return useMutation(saveProjectWDPA); +} + +export function useUploadWDPAsShapefile({ + requestConfig = { + method: 'POST', + }, +}: { + requestConfig?: AxiosRequestConfig; +}) { + const queryClient = useQueryClient(); + const { data: session } = useSession(); + + const uploadWDPAShapefile = ({ id, data }: { id: Project['id']; data: FormData }) => { + return UPLOADS.request<{ success: true }>({ + url: `/projects/${id}/protected-areas/shapefile`, + data, + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'multipart/form-data', + }, + ...requestConfig, + } as typeof requestConfig); + }; + + return useMutation(uploadWDPAShapefile, { + onSuccess: async (data, variables) => { + const { id: projectId } = variables; + await queryClient.invalidateQueries(['wdpas', projectId]); }, }); } diff --git a/app/hooks/wdpa/types.ts b/app/hooks/wdpa/types.ts deleted file mode 100644 index 050cc2e1d6..0000000000 --- a/app/hooks/wdpa/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AxiosRequestConfig } from 'axios'; - -export interface UseWDPACategoriesProps { - adminAreaId?: string; - customAreaId?: string; - scenarioId: string[] | string; -} - -export interface UseSaveScenarioProtectedAreasProps { - requestConfig?: AxiosRequestConfig; -} - -export interface SaveScenarioProtectedAreasProps { - data: unknown; - id: string[] | string; -} diff --git a/app/layout/info/upload-wdpas.tsx b/app/layout/info/upload-wdpas.tsx new file mode 100644 index 0000000000..491be9a9d6 --- /dev/null +++ b/app/layout/info/upload-wdpas.tsx @@ -0,0 +1,24 @@ +export const UploadWDPAsInfoButtonContent = (): JSX.Element => { + return ( +
+

+ When uploading shapefiles of protected areas, please make sure that: +

+
    +
  • this is a single zip file that includes all the components of a single shapefile;
  • +
  • + all the components are added to the “root”/top-level of the zip file itself (that is, not + within any folder within the zip file); +
  • +
  • + user-defined shapefile attributes are only considered for shapefiles of features, while + they are ignored for any other kind of shapefile (planning grid, lock-in/out, etc), so you + may consider excluding any attributes from shapefiles other than for features, in order to + keep the shapefile’s file size as small as possible. +
  • +
+
+ ); +}; + +export default UploadWDPAsInfoButtonContent; diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx index f2b6e6c4dd..c180110849 100644 --- a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx @@ -22,9 +22,11 @@ const InventoryTable = ({ }: InventoryTable): JSX.Element => { const noData = !loading && data?.length === 0; + const noDataCustom = !loading && data?.every((item) => !item.isCustom); + return ( <> - {loading && !data.length && ( + {loading && !data?.length && (
{columns.map((column) => { diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/index.tsx b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/index.tsx index e9c4302064..437cf1fee4 100644 --- a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/index.tsx @@ -28,7 +28,7 @@ const RowItem = ({ className="block h-4 w-4 checked:bg-blue-400" onChange={onSelectRow} value={id} - checked={selectedIds.includes(id)} + checked={isCustom && selectedIds.includes(id)} disabled={!isCustom} /> @@ -70,7 +70,13 @@ const RowItem = ({ {isCustom && ( - diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts index 55942fbe87..0466740b2a 100644 --- a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts @@ -1,7 +1,10 @@ import { ChangeEvent } from 'react'; +import { WDPAAttributes } from 'types/api/wdpa'; + export type DataItem = { id: string; + attributes?: WDPAAttributes; name: string; scenarios: number; tag?: string; diff --git a/app/layout/project/sidebar/project/inventory-panel/constants.ts b/app/layout/project/sidebar/project/inventory-panel/constants.ts index 9aedb32c9e..2daa375733 100644 --- a/app/layout/project/sidebar/project/inventory-panel/constants.ts +++ b/app/layout/project/sidebar/project/inventory-panel/constants.ts @@ -9,6 +9,7 @@ import FeatureUploadModal from './features/modals/upload'; import { InventoryPanel } from './types'; import ProtectedAreasTable from './wdpas'; import ProtectedAreasFooter from './wdpas/footer'; +import WDPAUploadModal from './wdpas/modals/upload'; export const INVENTORY_TABS = { 'protected-areas': { @@ -17,6 +18,7 @@ export const INVENTORY_TABS = { noData: 'No protected areas found.', TableComponent: ProtectedAreasTable, FooterComponent: ProtectedAreasFooter, + UploadModalComponent: WDPAUploadModal, }, 'cost-surface': { title: 'Cost Surface', diff --git a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx index c52a8162e2..b5d4071cba 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -81,7 +81,6 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): const handleSort = useCallback( (_sortType: (typeof filters)['sort']) => { const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; - setFilters((prevFilters) => ({ ...prevFilters, sort, diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/actions-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/actions-menu/index.tsx new file mode 100644 index 0000000000..de307bc624 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/actions-menu/index.tsx @@ -0,0 +1,96 @@ +import { useCallback, useState } from 'react'; + +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import DeleteModal from 'layout/project/sidebar/project/inventory-panel/wdpas/modals/delete'; +import EditModal from 'layout/project/sidebar/project/inventory-panel/wdpas/modals/edit'; +import { cn } from 'utils/cn'; + +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; +import TAG_SVG from 'svgs/ui/tag.svg?sprite'; + +const BUTTON_CLASSES = + 'flex items-center px-4 py-2 w-full text-sm cursor-pointer bg-gray-700 hover:bg-gray-500 transition transition-colors space-x-2 group'; + +const ICON_CLASSES = 'h-5 w-5 text-gray-400 group-hover:text-white'; + +const ActionsMenu = ({ + item, +}: { + item: { + id: string; + name: string; + scenarios: number; + tag: string; + custom: boolean; + }; +}): JSX.Element => { + const isDeletable = !item.custom && !item.scenarios; + + const [modalState, setModalState] = useState<{ edit: boolean; delete: boolean }>({ + edit: false, + delete: false, + }); + + const handleModal = useCallback((modalKey: keyof typeof modalState, isVisible: boolean) => { + setModalState((prevState) => ({ ...prevState, [modalKey]: isVisible })); + }, []); + + return ( +
    +
  • + + handleModal('edit', false)} + > + + +
  • + {isDeletable && ( +
  • + + { + handleModal('delete', false); + }} + > + + +
  • + )} +
+ ); +}; + +export default ActionsMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/index.tsx new file mode 100644 index 0000000000..6442968dfd --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/index.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; + +import Button from 'components/button'; +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import DeleteModal from 'layout/project/sidebar/project/inventory-panel/wdpas/modals/delete'; +import { WDPA } from 'types/api/wdpa'; + +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; + +const BUTTON_CLASSES = + 'col-span-1 flex items-center space-x-2 rounded-lg bg-gray-700 px-4 text-xs text-gray-50'; +const ICON_CLASSES = 'h-5 w-5 transition-colors text-gray-400 group-hover:text-gray-50'; + +const WDPABulkActionMenu = ({ + selectedWDPAIds, +}: { + selectedWDPAIds: WDPA['id'][]; +}): JSX.Element => { + const [modalState, setModalState] = useState<{ edit: boolean; delete: boolean }>({ + edit: false, + delete: false, + }); + + const handleModal = useCallback((modalKey: keyof typeof modalState, isVisible: boolean) => { + setModalState((prevState) => ({ ...prevState, [modalKey]: isVisible })); + }, []); + + return ( + <> +
+ + + {selectedWDPAIds.length} + + Selected + + + +
+ + handleModal('delete', false)} + > + + + + ); +}; + +export default WDPABulkActionMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/utils.ts b/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/utils.ts new file mode 100644 index 0000000000..0f0c3c0f56 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/utils.ts @@ -0,0 +1,28 @@ +import { Session } from 'next-auth'; + +import { Project } from 'types/api/project'; +import { WDPA } from 'types/api/wdpa'; + +import { API } from 'services/api'; + +export function bulkDeleteWDPAFromProject( + pid: Project['id'], + wdpaids: WDPA['id'][], + session: Session +) { + const deleteFeatureFromProject = ({ + pid, + wdpaid, + }: { + pid: Project['id']; + wdpaid: WDPA['id']; + }) => { + return API.delete(`/protected-areas/${wdpaid}`, { + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + }); + }; + + return Promise.all(wdpaids.map((wdpaid) => deleteFeatureFromProject({ pid, wdpaid }))); +} diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx index c564d99d16..1bf1e1d8b7 100644 --- a/app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx @@ -1,9 +1,143 @@ +import { useState, useCallback, useEffect, ChangeEvent } from 'react'; + +import { useRouter } from 'next/router'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { setSelectedWDPAs as setVisibleWDPAs } from 'store/slices/projects/[id]'; + +import { useProjectWDPAs } from 'hooks/wdpa'; + +import { WDPA } from 'types/api/wdpa'; + +import InventoryTable, { type DataItem } from '../components/inventory-table'; + +import ActionsMenu from './actions-menu'; +import WDPABulkActionMenu from './bulk-action-menu'; + +const WDPA_TABLE_COLUMNS = [ + { + name: 'fullName', + text: 'Name', + }, +]; + const InventoryPanelProtectedAreas = ({ noData: noDataMessage, }: { noData: string; }): JSX.Element => { - return
{noDataMessage}
; + const dispatch = useAppDispatch(); + + const { selectedWDPAs: visibleWDPAs, search } = useAppSelector( + (state) => state['/projects/[id]'] + ); + + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const [selectedWDPAIds, setSelectedWDPAIds] = useState([]); + const [filters, setFilters] = useState[1]>({ + sort: WDPA_TABLE_COLUMNS[0].name, + }); + + const allProjectWDPAsQuery = useProjectWDPAs( + pid, + { + ...filters, + search, + }, + { + select: (data) => + data?.map((wdpa) => ({ + id: wdpa.id, + attributes: wdpa.attributes, + })), + keepPreviousData: true, + placeholderData: [], + } + ); + + const WDPAIds = allProjectWDPAsQuery.data + ?.filter((wdpa) => wdpa.attributes.isCustom) + .map((wdpa) => wdpa.id); + + const handleSelectAll = useCallback( + (evt: ChangeEvent) => { + setSelectedWDPAIds(evt.target.checked ? WDPAIds : []); + }, + [WDPAIds] + ); + + const handleSelectWDPA = useCallback((evt: ChangeEvent) => { + if (evt.target.checked) { + setSelectedWDPAIds((prevSelectedWDPAs) => [...prevSelectedWDPAs, evt.target.value]); + } else { + setSelectedWDPAIds((prevSelectedWDPAs) => + prevSelectedWDPAs.filter((wdpaId) => wdpaId !== evt.target.value) + ); + } + }, []); + + useEffect(() => { + setSelectedWDPAIds([]); + }, [search]); + + const toggleSeeOnMap = useCallback( + (WDPAId: WDPA['id']) => { + const newSelectedWDPAs = [...visibleWDPAs]; + if (!newSelectedWDPAs.includes(WDPAId)) { + newSelectedWDPAs.push(WDPAId); + } else { + const i = newSelectedWDPAs.indexOf(WDPAId); + newSelectedWDPAs.splice(i, 1); + } + dispatch(setVisibleWDPAs(newSelectedWDPAs)); + }, + [dispatch, visibleWDPAs] + ); + + const handleSort = useCallback( + (_sortType: (typeof filters)['sort']) => { + const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; + + setFilters((prevFilters) => ({ + ...prevFilters, + sort, + })); + }, + [filters.sort] + ); + + const displayBulkActions = selectedWDPAIds.length > 0; + + const data: DataItem[] = allProjectWDPAsQuery.data?.map((wdpa) => ({ + ...wdpa, + name: wdpa.attributes.isCustom ? wdpa.attributes.fullName : wdpa.attributes.iucnCategory, + scenarios: wdpa.attributes.scenarioUsageCount, + isCustom: wdpa.attributes.isCustom, + isVisibleOnMap: visibleWDPAs?.includes(wdpa.id), + })); + + return ( +
+
+ +
+ {displayBulkActions && } +
+ ); }; export default InventoryPanelProtectedAreas; diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/delete/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/delete/index.tsx new file mode 100644 index 0000000000..731904ac51 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/delete/index.tsx @@ -0,0 +1,129 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useSession } from 'next-auth/react'; + +import { useToasts } from 'hooks/toast'; +import { useProjectWDPAs } from 'hooks/wdpa'; + +import { Button } from 'components/button/component'; +import Icon from 'components/icon/component'; +import { ModalProps } from 'components/modal'; +import { bulkDeleteWDPAFromProject } from 'layout/project/sidebar/project/inventory-panel/wdpas/bulk-action-menu/utils'; +import { WDPA } from 'types/api/wdpa'; + +import ALERT_SVG from 'svgs/ui/new-layout/alert.svg?sprite'; + +const DeleteModal = ({ + selectedWDPAIds, + onDismiss, +}: { + selectedWDPAIds: WDPA['id'][]; + onDismiss?: ModalProps['onDismiss']; +}): JSX.Element => { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + const { addToast } = useToasts(); + + const allProjectWDPAsQuery = useProjectWDPAs(pid, {}); + + const selectedWDPAs = useMemo(() => { + return allProjectWDPAsQuery.data?.filter(({ id }) => selectedWDPAIds.includes(id)); + }, [allProjectWDPAsQuery.data, selectedWDPAIds]); + + const WDPAsNames = selectedWDPAs.map(({ attributes }) => attributes.fullName); + + // ? the user will be able to delete the protected areas only if they are not being used by any scenario. + const haveScenarioAssociated = selectedWDPAs.some(({ attributes }) => + Boolean(attributes.scenarioUsageCount) + ); + + const handleBulkDelete = useCallback(() => { + const deletableWDPAsIds = selectedWDPAs.map(({ id }) => id); + + bulkDeleteWDPAFromProject(pid, deletableWDPAsIds, session) + .then(async () => { + await queryClient.invalidateQueries(['wdpas', pid]); + + onDismiss(); + + addToast( + 'delete-bulk-project-wdpas', + <> +

Success

+

The protected areas were deleted successfully.

+ , + { + level: 'success', + } + ); + }) + .catch(() => { + addToast( + 'delete-bulk-project-wdpas', + <> +

Error!

+

Something went wrong deleting the protected areas

+ , + { + level: 'error', + } + ); + }); + }, [selectedWDPAs, addToast, onDismiss, pid, queryClient, session]); + + return ( +
+

{`Delete protected area${ + selectedWDPAIds.length > 1 ? 's' : '' + }`}

+

+ {selectedWDPAIds.length > 1 ? ( +

+ + Are you sure you want to delete the following protected areas?
+ This action cannot be undone. +
+
    + {WDPAsNames.map((name) => ( +
  • {name}
  • + ))} +
+
+ ) : ( + + Are you sure you want to delete "{WDPAsNames[0]}" protected area?
+ This action cannot be undone. +
+ )} +

+
+ +

+ A protected area can be deleted ONLY if it's not being used by any scenario +

+
+
+ + +
+
+ ); +}; + +export default DeleteModal; diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/edit/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/edit/index.tsx new file mode 100644 index 0000000000..16e61017e4 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/edit/index.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useRef } from 'react'; + +import { Form as FormRFF, Field as FieldRFF, FormProps } from 'react-final-form'; +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useToasts } from 'hooks/toast'; +import { useEditWDPA, useProjectWDPAs } from 'hooks/wdpa'; + +import Button from 'components/button'; +import Field from 'components/forms/field'; +import Label from 'components/forms/label'; +import { composeValidators } from 'components/forms/validations'; +import { WDPA, WDPAAttributes } from 'types/api/wdpa'; + +export type FormValues = { fullName: WDPAAttributes['fullName'] }; + +const EditModal = ({ + wdpaId, + handleModal, +}: { + wdpaId: WDPA['id']; + handleModal: (modalKey: 'delete' | 'edit', isVisible: boolean) => void; +}): JSX.Element => { + const queryClient = useQueryClient(); + const { addToast } = useToasts(); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const formRef = useRef['form']>(null); + + const allProjectWDPAsQuery = useProjectWDPAs(pid, {}); + const editWDPAMutation = useEditWDPA({}); + + const onEditSubmit = useCallback( + (values: FormValues) => { + const { fullName } = values; + editWDPAMutation.mutate( + { + wdpaId: wdpaId, + data: { + name: fullName, + }, + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(['wdpas', pid]); + handleModal('edit', false); + addToast( + 'success-edit-wdpa', + <> +

Success!

+

Protected area edited

+ , + { + level: 'success', + } + ); + }, + onError: () => { + addToast( + 'error-edit-wdpa', + <> +

Error!

+

It is not possible to edit this protected area

+ , + { + level: 'error', + } + ); + }, + } + ); + }, + [addToast, editWDPAMutation, wdpaId, handleModal, pid, queryClient] + ); + + return ( + + initialValues={{ + fullName: allProjectWDPAsQuery.data?.[0]?.attributes.fullName, + }} + ref={formRef} + onSubmit={onEditSubmit} + render={({ form, handleSubmit }) => { + formRef.current = form; + + return ( +
+
+

Edit protected area

+ +
+ + name="fullName" + validate={composeValidators([{ presence: true }])} + > + {(fprops) => ( + + + + + + )} + +
+ +
+ + + +
+
+
+ ); + }} + /> + ); +}; + +export default EditModal; diff --git a/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/upload/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/upload/index.tsx new file mode 100644 index 0000000000..22009a83b2 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/wdpas/modals/upload/index.tsx @@ -0,0 +1,328 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useDropzone, DropzoneProps } from 'react-dropzone'; +import { Form as FormRFF, Field as FieldRFF, FormProps } from 'react-final-form'; +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { AxiosError, isAxiosError } from 'axios'; +import { motion } from 'framer-motion'; + +import { useToasts } from 'hooks/toast'; +import { useUploadWDPAsShapefile } from 'hooks/wdpa'; + +import Button from 'components/button'; +import Field from 'components/forms/field'; +import Input from 'components/forms/input'; +import Label from 'components/forms/label'; +import { composeValidators } from 'components/forms/validations'; +import Icon from 'components/icon'; +import InfoButton from 'components/info-button'; +import Loading from 'components/loading'; +import Modal from 'components/modal'; +import { PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE } from 'constants/file-uploader-size-limits'; +import UploadWDPAsInfoButtonContent from 'layout/info/upload-wdpas'; +import { WDPAAttributes } from 'types/api/wdpa'; +import { cn } from 'utils/cn'; +import { bytesToMegabytes } from 'utils/units'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +export type FormValues = { + name: WDPAAttributes['fullName']; + file: File; +}; + +export const WDPAUploadModal = ({ + isOpen = false, + onDismiss, +}: { + isOpen?: boolean; + onDismiss: () => void; +}): JSX.Element => { + const formRef = useRef['form']>(null); + + const [loading, setLoading] = useState(false); + const [successFile, setSuccessFile] = useState<{ name: FormValues['name'] }>(null); + + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const { addToast } = useToasts(); + const queryClient = useQueryClient(); + + const uploadWDPAsShapefileMutation = useUploadWDPAsShapefile({}); + + useEffect(() => { + return () => { + setSuccessFile(null); + }; + }, []); + + const onClose = useCallback(() => { + onDismiss(); + setSuccessFile(null); + }, [onDismiss]); + + const onDropAccepted = (acceptedFiles: Parameters[0]) => { + const f = acceptedFiles[0]; + setSuccessFile({ name: f.name }); + + formRef.current.change('file', f); + }; + + const onDropRejected = (rejectedFiles: Parameters[0]) => { + const r = rejectedFiles[0]; + + // `file-too-large` backend error message is not friendly. + // It'll display the max size in bytes which the average user may not understand. + const errors = r.errors.map((error) => { + return error.code === 'file-too-large' + ? { + ...error, + message: `File is larger than ${bytesToMegabytes( + PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE + )} MB`, + } + : error; + }); + + addToast( + 'drop-error', + <> +

Error!

+
    + {errors.map((e) => ( +
  • {e.message}
  • + ))} +
+ , + { + level: 'error', + } + ); + }; + + const onUploadSubmit = useCallback( + (values: FormValues) => { + setLoading(true); + const { file, name } = values; + + const data = new FormData(); + + data.append('file', file); + data.append('name', name); + + const mutationResponse = { + onSuccess: () => { + setSuccessFile({ ...successFile }); + onClose(); + addToast( + 'success-upload-wdpa-file', + <> +

Success!

+

File uploaded

+ , + { + level: 'success', + } + ); + queryClient.invalidateQueries(['wdpas', pid]); + }, + onError: (error: AxiosError | Error) => { + let errors: { status: number; title: string }[] = []; + + if (isAxiosError(error)) { + errors = [...error.response.data.errors]; + } else { + // ? in case of unknown error (not request error), display generic error message + errors = [{ status: 500, title: 'Something went wrong' }]; + } + + setSuccessFile(null); + + addToast( + 'error-upload-wdpa-csv', + <> +

Error

+
    + {errors.map((e) => ( +
  • {e.title}
  • + ))} +
+ , + { + level: 'error', + } + ); + }, + onSettled: () => { + setLoading(false); + }, + }; + + uploadWDPAsShapefileMutation.mutate({ data, id: `${pid}` }, mutationResponse); + }, + [pid, addToast, onClose, uploadWDPAsShapefileMutation, successFile] + ); + + const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ + multiple: false, + maxSize: PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE, + onDropAccepted, + onDropRejected, + }); + + return ( + + + initialValues={{ + name: '', + }} + ref={formRef} + onSubmit={onUploadSubmit} + render={({ form, handleSubmit }) => { + formRef.current = form; + + return ( +
+
+
+

Upload protected area

+ + + +
+ +
+ + {(fprops) => ( + + + + + )} + +
+ + {!successFile && ( +
+ + + {(props) => ( +
+
+ + +

+ Drag and drop your polygon data file +
+ or click here to upload +

+ +

{`Recommended file size < ${bytesToMegabytes( + PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE + )} MB`}

+ + +
+ +
+
Supported formats
+ + + {' '} +

+ List of supported file formats: +

+
    + Zipped: .shp (zipped shapefiles must include +
    + .shp, .shx, .dbf, and .prj files) +
+
+
+
+
+ )} +
+
+ )} + + {successFile && ( + +
+
Uploaded file:
+
+ + +
+
+
+ )} + +
+ + + +
+
+
+ ); + }} + /> + +
+ ); +}; + +export default WDPAUploadModal; diff --git a/app/layout/project/sidebar/scenario/grid-setup/protected-areas/categories/pa-uploader/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/protected-areas/categories/pa-uploader/index.tsx index b1ebb59f46..69e86bea4d 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/protected-areas/categories/pa-uploader/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/protected-areas/categories/pa-uploader/index.tsx @@ -24,7 +24,7 @@ import Icon from 'components/icon'; import InfoButton from 'components/info-button'; import Loading from 'components/loading'; import Uploader from 'components/uploader'; -import { PROTECTED_AREA_UPLOADER_MAX_SIZE } from 'constants/file-uploader-size-limits'; +import { PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE } from 'constants/file-uploader-size-limits'; import { cn } from 'utils/cn'; import { bytesToMegabytes } from 'utils/units'; @@ -89,7 +89,9 @@ export const ProtectedAreaUploader: React.FC = ({ return error.code === 'file-too-large' ? { ...error, - message: `File is larger than ${bytesToMegabytes(PROTECTED_AREA_UPLOADER_MAX_SIZE)} MB`, + message: `File is larger than ${bytesToMegabytes( + PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE + )} MB`, } : error; }); @@ -165,7 +167,7 @@ export const ProtectedAreaUploader: React.FC = ({ const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ multiple: false, - maxSize: PROTECTED_AREA_UPLOADER_MAX_SIZE, + maxSize: PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE, onDropAccepted, onDropRejected, }); @@ -259,7 +261,7 @@ export const ProtectedAreaUploader: React.FC = ({

{`Recommended file size < ${bytesToMegabytes( - PROTECTED_AREA_UPLOADER_MAX_SIZE + PROTECTED_AREA_UPLOADER_SHAPEFILE_MAX_SIZE )} MB`}

{ layerSettings, selectedFeatures: selectedFeaturesIds, selectedCostSurface: selectedCostSurfaceId, + selectedWDPAs: selectedWDPAsIds, } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); @@ -144,6 +146,16 @@ export const ProjectMap = (): JSX.Element => { }, }); + const WDPAsPreviewLayers = useWDPAPreviewLayer({ + wdpaIucnCategories: selectedWDPAsIds, + pid: `${pid}`, + active: true, + bbox, + options: { + ...layerSettings['wdpa-preview'], + }, + }); + const selectedPreviewFeatures = useMemo(() => { return selectedFeaturesData ?.map(({ featureClassName, id }) => ({ name: featureClassName, id })) @@ -170,14 +182,19 @@ export const ProjectMap = (): JSX.Element => { } ); - const LAYERS = [PUGridLayer, PUCompareLayer, PlanningAreaLayer, ...FeaturePreviewLayers].filter( - (l) => !!l - ); + const LAYERS = [ + PUGridLayer, + PUCompareLayer, + PlanningAreaLayer, + WDPAsPreviewLayers, + ...FeaturePreviewLayers, + ].filter((l) => !!l); const LEGEND = useLegend({ layers: [ ...(!!selectedFeaturesData?.length ? ['features-preview'] : []), ...(!!selectedCostSurfaceId ? ['cost'] : []), + ...(!!selectedWDPAsIds?.length ? ['wdpa-preview'] : []), ...(!!sid1 && !sid2 ? ['frequency'] : []), ...(!!sid1 && !!sid2 ? ['compare'] : []), diff --git a/app/services/wdpa/index.ts b/app/services/wdpa/index.ts deleted file mode 100644 index 241bf96313..0000000000 --- a/app/services/wdpa/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from 'axios'; - -const WDPA = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/scenarios`, - headers: { 'Content-Type': 'application/json' }, - transformResponse: (data) => { - try { - const parsedData = JSON.parse(data); - return { - data: parsedData, - meta: {}, - }; - } catch (error) { - return data; - } - }, -}); - -export default WDPA; diff --git a/app/store/slices/projects/[id].ts b/app/store/slices/projects/[id].ts index 06bb3d6cba..06bbb4f6eb 100644 --- a/app/store/slices/projects/[id].ts +++ b/app/store/slices/projects/[id].ts @@ -1,12 +1,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { CostSurface } from 'types/api/cost-surface'; +import { Feature } from 'types/api/feature'; +import { WDPA } from 'types/api/wdpa'; + interface ProjectShowStateProps { search: string; filters: Record | []; sort: string; layerSettings: Record; - selectedFeatures: string[]; - selectedCostSurface: string | null; + selectedCostSurface: CostSurface['id']; + selectedFeatures: Feature['id'][]; + selectedWDPAs: WDPA['id'][]; isSidebarOpen: boolean; } @@ -17,6 +22,7 @@ const initialState: ProjectShowStateProps = { layerSettings: {}, selectedFeatures: [], selectedCostSurface: null, + selectedWDPAs: [], isSidebarOpen: true, } satisfies ProjectShowStateProps; @@ -71,6 +77,10 @@ const projectsDetailSlice = createSlice({ ) => { state.selectedCostSurface = action.payload; }, + // WDPAs + setSelectedWDPAs: (state, action: PayloadAction) => { + state.selectedWDPAs = action.payload; + }, }, }); @@ -81,6 +91,7 @@ export const { setLayerSettings, setSelectedFeatures, setSelectedCostSurface, + setSelectedWDPAs, setSidebarVisibility, } = projectsDetailSlice.actions; diff --git a/app/types/api/wdpa.ts b/app/types/api/wdpa.ts new file mode 100644 index 0000000000..2919c0d69d --- /dev/null +++ b/app/types/api/wdpa.ts @@ -0,0 +1,27 @@ +import { Job } from './job'; + +export interface WDPAAttributes { + countryId: string; + designation?: string; + fullName: string; + iucnCategory: string; + scenarioUsageCount: number; + shapeLength?: number; + shapeArea?: number; + status?: Job['status']; + wdpaId: string; + isCustom?: boolean; +} + +export interface WDPA { + id: string; + type: string; + attributes: WDPAAttributes; +} + +export interface WDPACategory { + id: string; + kind: 'global' | 'project'; + name: string; + selected: boolean; +}