diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts new file mode 100644 index 0000000000..bfca3f3cd1 --- /dev/null +++ b/app/hooks/cost-surface/index.ts @@ -0,0 +1,102 @@ +import { useQuery, QueryObserverOptions, useMutation } from 'react-query'; + +import { useSession } from 'next-auth/react'; + +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'], + params: { search?: string; sort?: string } = {}, + queryOptions: QueryObserverOptions = {} +) { + const { data: session } = useSession(); + + const mockData: CostSurface[] = [ + { + id: 'b7454579-c48e-4e2f-8438-833280cb65d8', + name: 'Brazil 15 k Cost Surface', + isCustom: true, + scenarioUsageCount: 3, + }, + { + id: 'rfjghhrtersdtbkjshfw', + name: 'Cost Surface Rwanda B', + isCustom: true, + scenarioUsageCount: 0, + }, + { + id: '23275455HGVVCMSJHDFk', + name: 'Cost Surface Rwanda C', + isCustom: true, + scenarioUsageCount: 0, + }, + ]; + + return useQuery({ + queryKey: ['cost-surfaces', pid], + queryFn: async () => + API.request({ + method: 'GET', + // !TODO: change this to the correct endpoint + url: `/projects/${pid}/protected-areas`, + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + params, + }).then(({ data }) => mockData), + enabled: Boolean(pid), + ...queryOptions, + }); +} + +export function useEditProjectCostSurface() { + const { data: session } = useSession(); + + const editCostSurface = ({ + costSurfaceId, + projectId, + body = {}, + }: { + costSurfaceId: CostSurface['id']; + projectId: Project['id']; + body: Record; + }) => { + // TODO: change this to the correct endpoint + return API.patch( + `projects/${projectId}/cost-surfaces/${costSurfaceId}`, + { + ...body, + }, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + } + ); + }; + + 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/hooks/cost-surface/types.ts b/app/hooks/cost-surface/types.ts new file mode 100644 index 0000000000..050cc2e1d6 --- /dev/null +++ b/app/hooks/cost-surface/types.ts @@ -0,0 +1,16 @@ +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/hooks/map/constants.tsx b/app/hooks/map/constants.tsx index 509d1c0b9c..f19de55c71 100644 --- a/app/hooks/map/constants.tsx +++ b/app/hooks/map/constants.tsx @@ -254,7 +254,7 @@ export const LEGEND_LAYERS = { return { id: 'cost', - name: 'Cost surface', + name: options.cost.name, type: 'gradient', settingsManager: { opacity: true, diff --git a/app/hooks/map/types.ts b/app/hooks/map/types.ts index dfab51d8e6..0de5ab27e8 100644 --- a/app/hooks/map/types.ts +++ b/app/hooks/map/types.ts @@ -195,6 +195,7 @@ export interface UseLegend { wdpaIucnCategories?: string[]; wdpaThreshold?: number; cost?: { + name: string; min: number; max: number; }; 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/components/inventory-table/header-item/index.tsx b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/index.tsx index 2764498788..8d37654430 100644 --- a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/index.tsx @@ -6,23 +6,16 @@ import { cn } from 'utils/cn'; import { HeaderItem } from './types'; -const HeaderItem = ({ - className, - text, - name, - columns, - sorting, - onClick, -}: HeaderItem): JSX.Element => { +const HeaderItem = ({ className, text, name, sorting, onClick }: HeaderItem): JSX.Element => { const sortingMatches = /^(-?)(.+)$/.exec(sorting); const sortField = sortingMatches[2]; const sortOrder = sortingMatches[1] === '-' ? 'desc' : 'asc'; - const isActive = columns[name] === sortField; + const isActive = name === sortField; const handleClick = useCallback(() => { - onClick(columns[name]); - }, [onClick, columns, name]); + onClick(name); + }, [onClick, name]); return ( + handleModal('edit', false)} + > + + + + {isDeletable && ( +
  • + + { + handleModal('delete', false); + }} + > + + +
  • + )} + + ); +}; + +export default ActionsMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx new file mode 100644 index 0000000000..a196227b7e --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/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/cost-surfaces/modals/delete/index'; +import { CostSurface } from 'types/api/cost-surface'; + +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 CostSurfaceBulkActionMenu = ({ + selectedCostSurfacesIds, +}: { + selectedCostSurfacesIds: CostSurface['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 ( + <> +
    + + + {selectedCostSurfacesIds.length} + + Selected + + + +
    + + handleModal('delete', false)} + > + + + + ); +}; + +export default CostSurfaceBulkActionMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts new file mode 100644 index 0000000000..d458ead34d --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts @@ -0,0 +1,28 @@ +import { Session } from 'next-auth'; + +import { CostSurface } from 'types/api/cost-surface'; +import { Project } from 'types/api/project'; + +import PROJECTS from 'services/projects'; + +export function bulkDeleteCostSurfaceFromProject( + pid: Project['id'], + csids: CostSurface['id'][], + session: Session +) { + const deleteCostSurfaceFromProject = ({ + pid, + csid, + }: { + pid: Project['id']; + csid: CostSurface['id']; + }) => { + return PROJECTS.delete(`/${pid}/cost-surfaces/${csid}`, { + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + }); + }; + + return Promise.all(csids.map((csid) => deleteCostSurfaceFromProject({ pid, csid }))); +} diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx new file mode 100644 index 0000000000..7fc338cc86 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -0,0 +1,139 @@ +import { useState, useCallback, useEffect, ChangeEvent } from 'react'; + +import { useRouter } from 'next/router'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { setSelectedCostSurface as setVisibleCostSurface } from 'store/slices/projects/[id]'; + +import { useProjectCostSurfaces } from 'hooks/cost-surface'; + +import ActionsMenu from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/actions-menu'; +import CostSurfacesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu'; +import { CostSurface } from 'types/api/cost-surface'; + +import InventoryTable, { type DataItem } from '../components/inventory-table'; + +const COST_SURFACE_TABLE_COLUMNS = [ + { + name: 'name', + text: 'Name', + }, +]; + +const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { + const dispatch = useAppDispatch(); + const { selectedCostSurface: visibleCostSurface, search } = useAppSelector( + (state) => state['/projects/[id]'] + ); + + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const [selectedCostSurfaceIds, setSelectedCostSurfaceIds] = useState([]); + const [filters, setFilters] = useState[1]>({ + sort: COST_SURFACE_TABLE_COLUMNS[0].name, + }); + + const allProjectCostSurfacesQuery = useProjectCostSurfaces( + pid, + { + ...filters, + search, + }, + { + select: (data) => + data?.map((cs) => ({ + id: cs.id, + name: cs.name, + isCustom: cs.isCustom, + scenarioUsageCount: cs.scenarioUsageCount, + })), + keepPreviousData: true, + placeholderData: [], + } + ); + + const costSurfaceIds = allProjectCostSurfacesQuery.data?.map((cs) => cs.id); + + const handleSelectAll = useCallback( + (evt: ChangeEvent) => { + setSelectedCostSurfaceIds(evt.target.checked ? costSurfaceIds : []); + }, + [costSurfaceIds] + ); + + const handleSelectCostSurface = useCallback((evt: ChangeEvent) => { + if (evt.target.checked) { + setSelectedCostSurfaceIds((prevSelectedCostSurface) => [ + ...prevSelectedCostSurface, + evt.target.value, + ]); + } else { + setSelectedCostSurfaceIds((prevSelectedCostSurface) => + prevSelectedCostSurface.filter((costSurfaceId) => costSurfaceId !== evt.target.value) + ); + } + }, []); + + useEffect(() => { + setSelectedCostSurfaceIds([]); + }, [search]); + + const toggleSeeOnMap = useCallback( + (costSurfaceId: CostSurface['id']) => { + if (costSurfaceId === visibleCostSurface) { + dispatch(setVisibleCostSurface(null)); + } else { + dispatch(setVisibleCostSurface(costSurfaceId)); + } + }, + [dispatch, visibleCostSurface] + ); + + const handleSort = useCallback( + (_sortType: (typeof filters)['sort']) => { + const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; + + setFilters((prevFilters) => ({ + ...prevFilters, + sort, + })); + }, + [filters.sort] + ); + + const displayBulkActions = selectedCostSurfaceIds.length > 0; + + const data: DataItem[] = allProjectCostSurfacesQuery.data?.map((cs) => ({ + ...cs, + name: cs.name, + scenarios: cs.scenarioUsageCount, + isCustom: cs.isCustom, + isVisibleOnMap: visibleCostSurface === cs.id, + })); + + return ( +
    +
    + +
    + {displayBulkActions && ( + + )} +
    + ); +}; + +export default InventoryPanelCostSurface; 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 new file mode 100644 index 0000000000..61eb608f20 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx @@ -0,0 +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 => ( +
    +

    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/delete/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx new file mode 100644 index 0000000000..0fad5ab67a --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx @@ -0,0 +1,130 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useSession } from 'next-auth/react'; + +import { useProjectCostSurfaces } from 'hooks/cost-surface'; +import { useToasts } from 'hooks/toast'; + +import { Button } from 'components/button/component'; +import Icon from 'components/icon/component'; +import { ModalProps } from 'components/modal'; +import { bulkDeleteCostSurfaceFromProject } from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils'; +import { CostSurface } from 'types/api/cost-surface'; + +import ALERT_SVG from 'svgs/ui/new-layout/alert.svg?sprite'; + +const DeleteModal = ({ + selectedCostSurfacesIds, + onDismiss, +}: { + selectedCostSurfacesIds: CostSurface['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 allProjectCostSurfacesQuery = useProjectCostSurfaces(pid, {}); + + const selectedCostSurfaces = useMemo(() => { + return allProjectCostSurfacesQuery.data?.filter(({ id }) => + selectedCostSurfacesIds.includes(id) + ); + }, [allProjectCostSurfacesQuery.data, selectedCostSurfacesIds]); + + const costSurfaceNames = selectedCostSurfaces.map(({ name }) => name); + // ? the user will be able to delete the cost surfaces only if they are not being used by any scenario. + const haveScenarioAssociated = selectedCostSurfaces.some(({ scenarioUsageCount }) => + Boolean(scenarioUsageCount) + ); + + const handleBulkDelete = useCallback(() => { + const deletableCostSurfaceIds = selectedCostSurfaces.map(({ id }) => id); + + bulkDeleteCostSurfaceFromProject(pid, deletableCostSurfaceIds, session) + .then(async () => { + await queryClient.invalidateQueries(['cost-surfaces', pid]); + + onDismiss(); + + addToast( + 'delete-bulk-project-cost-surfaces', + <> +

    Success

    +

    The cost surfaces were deleted successfully.

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

    Error!

    +

    Something went wrong deleting the cost surfaces.

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

    {`Delete cost surface${ + selectedCostSurfacesIds.length > 1 ? 's' : '' + }`}

    +

    + {selectedCostSurfacesIds.length > 1 ? ( +

    + + Are you sure you want to delete the following cost surfaces?
    + This action cannot be undone. +
    +
      + {costSurfaceNames.map((name) => ( +
    • {name}
    • + ))} +
    +
    + ) : ( + + Are you sure you want to delete "{costSurfaceNames[0]}" cost surface?
    + This action cannot be undone. +
    + )} +

    +
    + +

    + A cost surface 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/cost-surfaces/modals/edit/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx new file mode 100644 index 0000000000..966b16fee9 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/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 { useEditProjectCostSurface, useProjectCostSurfaces } from 'hooks/cost-surface'; +import { useToasts } from 'hooks/toast'; + +import Button from 'components/button'; +import Field from 'components/forms/field'; +import Label from 'components/forms/label'; +import { composeValidators } from 'components/forms/validations'; +import { CostSurface } from 'types/api/cost-surface'; + +export type FormValues = { name: CostSurface['name'] }; + +const EditModal = ({ + costSurfaceId, + handleModal, +}: { + costSurfaceId: CostSurface['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 allProjectCostSurfacesQuery = useProjectCostSurfaces(pid, {}); + + const editProjectCostSurfaceMutation = useEditProjectCostSurface(); + + const onEditSubmit = useCallback( + (values: FormValues) => { + const { name } = values; + + editProjectCostSurfaceMutation.mutate( + { + costSurfaceId, + projectId: pid, + body: { + name, + }, + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(['cost-surfaces', pid]); + handleModal('edit', false); + addToast( + 'success-edit-cost-surfaces', + <> +

    Success!

    +

    Cost surface edited

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

    Error!

    +

    It is not possible to edit this cost surface

    + , + { + level: 'error', + } + ); + }, + } + ); + }, + [addToast, costSurfaceId, editProjectCostSurfaceMutation, handleModal, pid, queryClient] + ); + + return ( + + initialValues={{ + name: allProjectCostSurfacesQuery.data?.[0]?.name, + }} + ref={formRef} + onSubmit={onEditSubmit} + render={({ form, handleSubmit }) => { + formRef.current = form; + + return ( +
    +
    +

    Edit cost surface

    + +
    + name="name" validate={composeValidators([{ presence: true }])}> + {(fprops) => ( + + + + + + )} + +
    + +
    + + + +
    +
    +
    + ); + }} + /> + ); +}; + +export default EditModal; 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 new file mode 100644 index 0000000000..370c17b73e --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx @@ -0,0 +1,358 @@ +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 { useRouter } from 'next/router'; + +import { AxiosError, isAxiosError } from 'axios'; +import { motion } from 'framer-motion'; + +import { useUploadProjectCostSurface } from 'hooks/cost-surface'; +import { useDownloadShapefileTemplate } from 'hooks/projects'; +import { useToasts } from 'hooks/toast'; + +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 { 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: CostSurface['name']; + file: File; +}; + +export const CostSurfaceUploadModal = ({ + 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 uploadProjectCostSurfaceMutation = useUploadProjectCostSurface(); + + const downloadShapefileTemplateMutation = useDownloadShapefileTemplate(); + + 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(COST_SURFACE_UPLOADER_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); + + 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' }]; + } + + setSuccessFile(null); + + addToast( + 'error-upload-cost-surface-csv', + <> +

    Error

    +
      + {errors.map((e) => ( +
    • {e.title}
    • + ))} +
    + , + { + level: 'error', + } + ); + }, + onSettled: () => { + setLoading(false); + }, + } + ); + }, + [pid, addToast, onClose, uploadProjectCostSurfaceMutation, successFile] + ); + + const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ + multiple: false, + maxSize: COST_SURFACE_UPLOADER_MAX_SIZE, + onDropAccepted, + onDropRejected, + }); + + const onDownloadTemplate = useCallback(() => { + downloadShapefileTemplateMutation.mutate( + { pid }, + { + onError: () => { + addToast( + 'download-error', + <> +

    Error!

    +
      Template not downloaded
    + , + { + level: 'error', + } + ); + }, + } + ); + }, [pid, downloadShapefileTemplateMutation, addToast]); + + return ( + + + initialValues={{ + name: '', + }} + ref={formRef} + onSubmit={onUploadSubmit} + render={({ form, handleSubmit }) => { + formRef.current = form; + + return ( +
    +
    +
    +

    Upload cost surface

    + + + +
    + +

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

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

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

    + +

    {`Recommended file size < ${bytesToMegabytes( + COST_SURFACE_UPLOADER_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 CostSurfaceUploadModal; 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 db744bb169..c52a8162e2 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -7,17 +7,23 @@ import { setSelectedFeatures as setVisibleFeatures } from 'store/slices/projects import { useAllFeatures } from 'hooks/features'; +import ActionsMenu from 'layout/project/sidebar/project/inventory-panel/features/actions-menu'; +import FeaturesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu'; import { Feature } from 'types/api/feature'; import InventoryTable, { type DataItem } from '../components/inventory-table'; -import ActionsMenu from './actions-menu'; -import FeaturesBulkActionMenu from './bulk-action-menu'; - -const FEATURES_TABLE_COLUMNS = { - name: 'featureClassName', - tag: 'tag', -}; +const FEATURES_TABLE_COLUMNS = [ + { + name: 'featureClassName', + text: 'Name', + }, + { + name: 'tag', + text: 'Type', + className: 'flex flex-1 justify-start py-2 pl-14', + }, +]; const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); @@ -27,7 +33,7 @@ const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): ); const [filters, setFilters] = useState[1]>({ - sort: 'featureClassName', + sort: FEATURES_TABLE_COLUMNS[0].name, }); const [selectedFeaturesIds, setSelectedFeaturesIds] = useState([]); const { query } = useRouter(); diff --git a/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx index af00b70443..c64e26aec5 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/modals/upload/index.tsx @@ -34,7 +34,7 @@ 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 UploadFeaturesInfoButtonContent from 'layout/info/upload-features'; import { Feature } from 'types/api/feature'; import { cn } from 'utils/cn'; import { bytesToMegabytes } from 'utils/units'; diff --git a/app/layout/project/sidebar/project/inventory-panel/protected-areas/footer/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/footer/index.tsx similarity index 100% rename from app/layout/project/sidebar/project/inventory-panel/protected-areas/footer/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/wdpas/footer/index.tsx diff --git a/app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx b/app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx similarity index 100% rename from app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/wdpas/index.tsx diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/uploader/component.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/uploader/component.tsx index 48b31f0869..45ca8ae7df 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/uploader/component.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/uploader/component.tsx @@ -21,7 +21,7 @@ import InfoButton from 'components/info-button'; import Loading from 'components/loading'; import Uploader from 'components/uploader'; import { FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE } from 'constants/file-uploader-size-limits'; -import UploadFeaturesInfoButtonContent from 'constants/info-button-content/upload-features'; +import UploadFeaturesInfoButtonContent from 'layout/info/upload-features'; import { cn } from 'utils/cn'; import { bytesToMegabytes } from 'utils/units'; diff --git a/app/layout/projects/show/map/index.tsx b/app/layout/projects/show/map/index.tsx index dcd9087deb..717a2187d4 100644 --- a/app/layout/projects/show/map/index.tsx +++ b/app/layout/projects/show/map/index.tsx @@ -11,6 +11,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import pick from 'lodash/pick'; import { useAccessToken } from 'hooks/auth'; +import { useProjectCostSurfaces } from 'hooks/cost-surface'; import { useAllFeatures } from 'hooks/features'; import { useLegend, @@ -52,6 +53,7 @@ export const ProjectMap = (): JSX.Element => { isSidebarOpen, layerSettings, selectedFeatures: selectedFeaturesIds, + selectedCostSurface: selectedCostSurfaceId, } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); @@ -106,19 +108,19 @@ export const ProjectMap = (): JSX.Element => { const PUGridLayer = usePUGridLayer({ active: rawScenariosIsFetched && rawScenariosData && !!rawScenariosData.length && !sid2, sid: sid ? `${sid}` : null, - include: 'results', - sublayers: [...(sid1 && !sid2 ? ['frequency'] : [])], + include: 'results,cost', + sublayers: [ + ...(sid1 && !sid2 ? ['frequency'] : []), + ...(!!selectedCostSurfaceId ? ['cost'] : []), + ], options: { + cost: { min: 1, max: 100 }, settings: { pugrid: layerSettings.pugrid, 'wdpa-percentage': layerSettings['wdpa-percentage'], features: layerSettings.features, cost: layerSettings.cost, - 'lock-in': layerSettings['lock-in'], - 'lock-out': layerSettings['lock-out'], - 'lock-available': layerSettings['lock-available'], frequency: layerSettings.frequency, - solution: layerSettings.solution, }, }, }); @@ -152,6 +154,22 @@ export const ProjectMap = (): JSX.Element => { }); }, [selectedFeaturesIds, selectedFeaturesData]); + const allProjectCostSurfacesQuery = useProjectCostSurfaces( + pid, + {}, + { + select: (data) => + data + ?.map((cs) => ({ + id: cs.id, + name: cs.name, + })) + .find((cs) => cs.id === selectedCostSurfaceId), + keepPreviousData: true, + placeholderData: [], + } + ); + const LAYERS = [PUGridLayer, PUCompareLayer, PlanningAreaLayer, ...FeaturePreviewLayers].filter( (l) => !!l ); @@ -159,6 +177,7 @@ export const ProjectMap = (): JSX.Element => { const LEGEND = useLegend({ layers: [ ...(!!selectedFeaturesData?.length ? ['features-preview'] : []), + ...(!!selectedCostSurfaceId ? ['cost'] : []), ...(!!sid1 && !sid2 ? ['frequency'] : []), ...(!!sid1 && !!sid2 ? ['compare'] : []), @@ -168,6 +187,7 @@ export const ProjectMap = (): JSX.Element => { ], options: { layerSettings, + cost: { name: allProjectCostSurfacesQuery.data?.name, min: 1, max: 100 }, items: selectedPreviewFeatures, }, }); diff --git a/app/services/api/index.ts b/app/services/api/index.ts new file mode 100644 index 0000000000..d44e3b28f4 --- /dev/null +++ b/app/services/api/index.ts @@ -0,0 +1,56 @@ +import axios, { AxiosResponse, CreateAxiosDefaults, isAxiosError } from 'axios'; +import Jsona from 'jsona'; +import { signOut } from 'next-auth/react'; + +const dataFormatter = new Jsona(); + +const APIConfig: CreateAxiosDefaults = { + baseURL: `${process.env.NEXT_PUBLIC_API_URL}/api/v1`, + headers: { 'Content-Type': 'application/json' }, +} satisfies CreateAxiosDefaults; + +export const JSONAPI = axios.create({ + ...APIConfig, + transformResponse: (data) => { + try { + const parsedData = JSON.parse(data); + return { + data: dataFormatter.deserialize(parsedData), + meta: parsedData.meta, + }; + } catch (error: unknown) { + if (isAxiosError(error)) { + throw new Error(error.response.statusText); + } + throw error; + } + }, +}); + +const onResponseSuccess = (response: AxiosResponse) => response; + +const onResponseError = async (error) => { + // Any status codes that falls outside the range of 2xx cause this function to trigger + if (isAxiosError(error)) { + if (error.response.status === 401) { + await signOut(); + } + } + // Do something with response error + return Promise.reject(error as Error); +}; + +JSONAPI.interceptors.response.use(onResponseSuccess, onResponseError); + +export const API = axios.create({ + ...APIConfig, +}); + +API.interceptors.response.use(onResponseSuccess, onResponseError); + +const APIInstances = { + JSONAPI, + API, +}; + +export default APIInstances; diff --git a/app/store/slices/projects/[id].ts b/app/store/slices/projects/[id].ts index 1cb34c932a..06bb3d6cba 100644 --- a/app/store/slices/projects/[id].ts +++ b/app/store/slices/projects/[id].ts @@ -6,6 +6,7 @@ interface ProjectShowStateProps { sort: string; layerSettings: Record; selectedFeatures: string[]; + selectedCostSurface: string | null; isSidebarOpen: boolean; } @@ -15,6 +16,7 @@ const initialState: ProjectShowStateProps = { sort: '-lastModifiedAt', layerSettings: {}, selectedFeatures: [], + selectedCostSurface: null, isSidebarOpen: true, } satisfies ProjectShowStateProps; @@ -62,6 +64,13 @@ const projectsDetailSlice = createSlice({ ) => { state.selectedFeatures = action.payload; }, + // COST SURFACE + setSelectedCostSurface: ( + state, + action: PayloadAction + ) => { + state.selectedCostSurface = action.payload; + }, }, }); @@ -71,6 +80,7 @@ export const { setSort, setLayerSettings, setSelectedFeatures, + setSelectedCostSurface, setSidebarVisibility, } = projectsDetailSlice.actions; diff --git a/app/types/api/cost-surface.ts b/app/types/api/cost-surface.ts new file mode 100644 index 0000000000..7390ea12ba --- /dev/null +++ b/app/types/api/cost-surface.ts @@ -0,0 +1,6 @@ +export interface CostSurface { + id: string; + name: string; + isCustom: boolean; + scenarioUsageCount: number; +}