diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts index fbc6cb9143..4bd03899dd 100644 --- a/app/hooks/cost-surface/index.ts +++ b/app/hooks/cost-surface/index.ts @@ -6,6 +6,7 @@ import { CostSurface } from 'types/api/cost-surface'; import { Project } from 'types/api/project'; import { API } from 'services/api'; +import UPLOADS from 'services/uploads'; export function useProjectCostSurfaces( pid: Project['id'], @@ -49,7 +50,7 @@ export function useProjectCostSurfaces( }); } -export function useEditCostSurface() { +export function useEditProjectCostSurface() { const { data: session } = useSession(); const editCostSurface = ({ @@ -77,3 +78,22 @@ export function useEditCostSurface() { return useMutation(editCostSurface); } + +export function useUploadProjectCostSurface() { + const { data: session } = useSession(); + + const uploadProjectCostSurface = ({ id, data }: { id: CostSurface['id']; data: FormData }) => { + return UPLOADS.request({ + method: 'POST', + // TODO: change this to the correct endpoint + url: `/projects/${id}/cost-surface/shapefile`, + data, + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'Content-Type': 'multipart/form-data', + }, + }); + }; + + return useMutation(uploadProjectCostSurface); +} diff --git a/app/layout/info/upload-cost-surface.tsx b/app/layout/info/upload-cost-surface.tsx new file mode 100644 index 0000000000..67e132e1b4 --- /dev/null +++ b/app/layout/info/upload-cost-surface.tsx @@ -0,0 +1,10 @@ +export const UploadCostSurfaceInfoButtonContent = (): JSX.Element => { + return ( +
+

List of supported file formats:

+

Zipped: .shp (zipped shapefiles must include .shp, .shx, .dbf, and .prj files)

+
+ ); +}; + +export default UploadCostSurfaceInfoButtonContent; diff --git a/app/constants/info-button-content/upload-features.tsx b/app/layout/info/upload-features.tsx similarity index 100% rename from app/constants/info-button-content/upload-features.tsx rename to app/layout/info/upload-features.tsx diff --git a/app/layout/project/sidebar/project/inventory-panel/constants.ts b/app/layout/project/sidebar/project/inventory-panel/constants.ts index eb66ee3b1a..9aedb32c9e 100644 --- a/app/layout/project/sidebar/project/inventory-panel/constants.ts +++ b/app/layout/project/sidebar/project/inventory-panel/constants.ts @@ -2,6 +2,7 @@ import { NavigationInventoryTabs } from 'layout/project/navigation/types'; import CostSurfaceTable from './cost-surfaces'; import CostSurfaceInfo from './cost-surfaces/info'; +import CostSurfaceUploadModal from './cost-surfaces/modals/upload'; import FeaturesTable from './features'; import FeaturesInfo from './features/info'; import FeatureUploadModal from './features/modals/upload'; @@ -22,6 +23,7 @@ export const INVENTORY_TABS = { search: 'Search cost surfaces', noData: 'No cost surfaces found.', InfoComponent: CostSurfaceInfo, + UploadModalComponent: CostSurfaceUploadModal, TableComponent: CostSurfaceTable, }, features: { diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx index 6c5bc50ae0..61eb608f20 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx @@ -1,28 +1,30 @@ +import Image from 'next/image'; + import COST_LAND_IMG from 'images/info-buttons/img_cost_surface_marine.png'; import COST_SEA_IMG from 'images/info-buttons/img_cost_surface_terrestrial.png'; -const CostSurfaceInfo = (): JSX.Element => { - return ( - <> -

What is a Cost Surface?

-
-

- Marxan aims to minimize socio-economic impacts and conflicts between uses through what is - called the “cost” surface. In conservation planning, cost data may reflect acquisition, - management, or opportunity costs ($), but may also reflect non-monetary impacts. Proxies - are commonly used in absence of fine-scale socio-economic information. A default value for - cost will be the planning unit area but you can upload your cost surface. -

-

- In the examples below, we illustrate how distance from a city, road or port can be used as - a proxy cost surface. In these examples, areas with many competing activities will make a - planning unit cost more than areas further away with less competition for access. -

- Cost sea - Cost Land +const CostSurfaceInfo = (): JSX.Element => ( +
+

What is a Cost Surface?

+
+

+ Marxan aims to minimize socio-economic impacts and conflicts between uses through what is + called the “cost” surface. In conservation planning, cost data may reflect acquisition, + management, or opportunity costs ($), but may also reflect non-monetary impacts. Proxies are + commonly used in absence of fine-scale socio-economic information. A default value for cost + will be the planning unit area but you can upload your cost surface. +

+

+ In the examples below, we illustrate how distance from a city, road or port can be used as a + proxy cost surface. In these examples, areas with many competing activities will make a + planning unit cost more than areas further away with less competition for access. +

+
+ Cost sea + Cost Land
- - ); -}; +
+
+); export default CostSurfaceInfo; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx index 6b08fc325c..966b16fee9 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx @@ -5,7 +5,7 @@ import { useQueryClient } from 'react-query'; import { useRouter } from 'next/router'; -import { useEditCostSurface, useProjectCostSurfaces } from 'hooks/cost-surface'; +import { useEditProjectCostSurface, useProjectCostSurfaces } from 'hooks/cost-surface'; import { useToasts } from 'hooks/toast'; import Button from 'components/button'; @@ -32,13 +32,13 @@ const EditModal = ({ const allProjectCostSurfacesQuery = useProjectCostSurfaces(pid, {}); - const editCostSurfaceMutation = useEditCostSurface(); + const editProjectCostSurfaceMutation = useEditProjectCostSurface(); const onEditSubmit = useCallback( (values: FormValues) => { const { name } = values; - editCostSurfaceMutation.mutate( + editProjectCostSurfaceMutation.mutate( { costSurfaceId, projectId: pid, @@ -76,7 +76,7 @@ const EditModal = ({ } ); }, - [addToast, costSurfaceId, editCostSurfaceMutation, handleModal, pid, queryClient] + [addToast, costSurfaceId, editProjectCostSurfaceMutation, handleModal, pid, queryClient] ); return ( diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx index af00b70443..311adea9ac 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx @@ -1,11 +1,4 @@ -import React, { - ElementRef, - InputHTMLAttributes, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +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'; @@ -15,9 +8,8 @@ import { useRouter } from 'next/router'; import { AxiosError, isAxiosError } from 'axios'; import { motion } from 'framer-motion'; -import { useUploadFeaturesCSV, useUploadFeaturesShapefile } from 'hooks/features'; +import { useUploadProjectCostSurface } from 'hooks/cost-surface'; import { useDownloadShapefileTemplate } from 'hooks/projects'; -import { useProjectTags } from 'hooks/projects'; import { useToasts } from 'hooks/toast'; import Button from 'components/button'; @@ -29,25 +21,20 @@ import Icon from 'components/icon'; import InfoButton from 'components/info-button'; import Loading from 'components/loading'; import Modal from 'components/modal'; -import UploadTabs from 'components/upload-tabs'; -import { - FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE, - FEATURES_UPLOADER_CSV_MAX_SIZE, -} from 'constants/file-uploader-size-limits'; -import UploadFeaturesInfoButtonContent from 'constants/info-button-content/upload-features'; -import { Feature } from 'types/api/feature'; +import { COST_SURFACE_UPLOADER_MAX_SIZE } from 'constants/file-uploader-size-limits'; +import UploadCostSurfacesInfoButtonContent from 'layout/info/upload-cost-surface'; +import { CostSurface } from 'types/api/cost-surface'; import { cn } from 'utils/cn'; import { bytesToMegabytes } from 'utils/units'; import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; export type FormValues = { - name: string; + name: CostSurface['name']; file: File; - tag: Feature['tag']; }; -export const FeatureUploadModal = ({ +export const CostSurfaceUploadModal = ({ isOpen = false, onDismiss, }: { @@ -55,48 +42,19 @@ export const FeatureUploadModal = ({ onDismiss: () => void; }): JSX.Element => { const formRef = useRef['form']>(null); - const tagsSectionRef = useRef>(null); const [loading, setLoading] = useState(false); const [successFile, setSuccessFile] = useState<{ name: FormValues['name'] }>(null); - const [uploadMode, saveUploadMode] = useState<'shapefile' | 'csv'>('shapefile'); - const [tagsMenuOpen, setTagsMenuOpen] = useState(false); - const [tagIsDone, setTagIsDone] = useState(false); const { query } = useRouter(); const { pid } = query as { pid: string }; const { addToast } = useToasts(); - const tagsQuery = useProjectTags(pid); - - const uploadFeaturesShapefileMutation = useUploadFeaturesShapefile({ - requestConfig: { - method: 'POST', - }, - }); - - const uploadFeaturesCSVMutation = useUploadFeaturesCSV({}); + const uploadProjectCostSurfaceMutation = useUploadProjectCostSurface(); const downloadShapefileTemplateMutation = useDownloadShapefileTemplate(); - const UPLOADER_MAX_SIZE = - uploadMode === 'shapefile' - ? FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE - : FEATURES_UPLOADER_CSV_MAX_SIZE; - - useEffect(() => { - const handleClickOutside = (event) => { - if (tagsSectionRef.current && !tagsSectionRef.current.contains(event.target)) { - setTagsMenuOpen(false); - } - }; - document.addEventListener('click', handleClickOutside, true); - return () => { - document.removeEventListener('click', handleClickOutside, true); - }; - }, []); - useEffect(() => { return () => { setSuccessFile(null); @@ -124,7 +82,7 @@ export const FeatureUploadModal = ({ return error.code === 'file-too-large' ? { ...error, - message: `File is larger than ${bytesToMegabytes(UPLOADER_MAX_SIZE)} MB`, + message: `File is larger than ${bytesToMegabytes(COST_SURFACE_UPLOADER_MAX_SIZE)} MB`, } : error; }); @@ -148,94 +106,69 @@ export const FeatureUploadModal = ({ const onUploadSubmit = useCallback( (values: FormValues) => { setLoading(true); - const { file, name, tag } = values; + const { file, name } = values; const data = new FormData(); data.append('file', file); data.append('name', name); - data.append('tagName', tag); - - const mutationResponse = { - onSuccess: () => { - setSuccessFile({ ...successFile }); - onClose(); - addToast( - 'success-upload-feature-file', - <> -

Success!

-

File uploaded

- , - { - level: 'success', - } - ); - }, - 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-feature-csv', - <> -

Error

-
    - {errors.map((e) => ( -
  • {e.title}
  • - ))} -
- , - { - level: 'error', + uploadProjectCostSurfaceMutation.mutate( + { data, id: `${pid}` }, + { + onSuccess: () => { + setSuccessFile({ ...successFile }); + onClose(); + addToast( + 'success-upload-cost-surface-file', + <> +

Success!

+

File uploaded

+ , + { + level: 'success', + } + ); + }, + 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' }]; } - ); - }, - onSettled: () => { - setLoading(false); - }, - }; - if (uploadMode === 'shapefile') { - uploadFeaturesShapefileMutation.mutate({ data, id: `${pid}` }, mutationResponse); - } - - if (uploadMode === 'csv') { - uploadFeaturesCSVMutation.mutate({ data, id: `${pid}` }, mutationResponse); - } + setSuccessFile(null); + + addToast( + 'error-upload-cost-surface-csv', + <> +

Error

+
    + {errors.map((e) => ( +
  • {e.title}
  • + ))} +
+ , + { + level: 'error', + } + ); + }, + onSettled: () => { + setLoading(false); + }, + } + ); }, - [ - pid, - addToast, - onClose, - uploadMode, - uploadFeaturesShapefileMutation, - uploadFeaturesCSVMutation, - successFile, - ] - ); - - const handleKeyPress = useCallback( - (event: Parameters['onKeyDown']>[0]) => { - if (event.key === 'Enter') { - setTagIsDone(true); - formRef.current.change('tag', event.currentTarget.value); - setTagsMenuOpen(false); - } - }, - [formRef] + [pid, addToast, onClose, uploadProjectCostSurfaceMutation, successFile] ); const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ multiple: false, - maxSize: UPLOADER_MAX_SIZE, + maxSize: COST_SURFACE_UPLOADER_MAX_SIZE, onDropAccepted, onDropRejected, }); @@ -261,125 +194,49 @@ export const FeatureUploadModal = ({ }, [pid, downloadShapefileTemplateMutation, addToast]); return ( - + initialValues={{ - tag: '', + name: '', }} ref={formRef} onSubmit={onUploadSubmit} - render={({ form, handleSubmit, values }) => { + render={({ form, handleSubmit }) => { formRef.current = form; return (
-

Upload feature

+

Upload cost surface

- +
- saveUploadMode(mode)} /> - {uploadMode === 'csv' && ( -

- Please download and fill in the{' '} - {' '} - before upload. -

- )} - - {uploadMode === 'shapefile' && ( -
- - {(fprops) => ( - - - - - )} - -
- )} - - {uploadMode === 'shapefile' && ( -
- - {(fprops) => ( - - - - {(!values.tag || !tagIsDone) && ( -
- setTagsMenuOpen(true)} - onBlur={() => setTagIsDone(true)} - onKeyDown={handleKeyPress} - /> - - {tagsMenuOpen && ( -
-
Recent:
-
- {tagsQuery.data?.map((tag) => ( - - ))} -
-
- )} -
- )} - - {values.tag && tagIsDone && ( -
-
-

{values.tag}

-
- -
- )} -
- )} -
-
- )} +

+ Please download and fill in the{' '} + {' '} + before upload. +

+ +
+ + {(fprops) => ( + + + + + )} + +
{!successFile && (
@@ -404,14 +261,13 @@ export const FeatureUploadModal = ({

- Drag and drop your{' '} - {uploadMode === 'shapefile' ? 'polygon data file' : 'feature file'} + Drag and drop your polygon data file
or click here to upload

{`Recommended file size < ${bytesToMegabytes( - UPLOADER_MAX_SIZE + COST_SURFACE_UPLOADER_MAX_SIZE )} MB`}