From 34175b32fb3bdd509944b02190206f048cba8586 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Tue, 29 Aug 2023 16:50:58 +0200 Subject: [PATCH 01/11] list cost surfaces --- app/hooks/cost-surface/index.ts | 49 +++++++ app/hooks/cost-surface/types.ts | 16 +++ .../components/inventory-table/types.ts | 2 +- .../inventory-panel/cost-surface/index.tsx | 132 +++++++++++++++++- .../inventory-panel/features/index.tsx | 5 +- app/services/api/index.ts | 56 ++++++++ app/store/slices/projects/[id].ts | 10 ++ app/types/api/cost-surface.ts | 5 + 8 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 app/hooks/cost-surface/index.ts create mode 100644 app/hooks/cost-surface/types.ts create mode 100644 app/services/api/index.ts create mode 100644 app/types/api/cost-surface.ts diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts new file mode 100644 index 0000000000..b9f3c59e2f --- /dev/null +++ b/app/hooks/cost-surface/index.ts @@ -0,0 +1,49 @@ +import { useQuery, QueryObserverOptions } 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'; + +export function useProjectCostSurfaces( + pid: Project['id'], + params: { search?: string; sort?: string; filters?: Record } = {}, + queryOptions: QueryObserverOptions = {} +) { + const { data: session } = useSession(); + + const mockData: CostSurface[] = [ + { + id: 'Cost Surface Rwanda A', + name: 'Cost Surface Rwanda A', + scenarioUsageCount: 3, + }, + { + id: 'Cost Surface Rwanda B', + name: 'Cost Surface Rwanda B', + scenarioUsageCount: 0, + }, + { + id: 'Cost Surface Rwanda C', + name: 'Cost Surface Rwanda C', + scenarioUsageCount: 0, + }, + ]; + + return useQuery({ + queryKey: ['cost-surfaces', pid], + queryFn: async () => + API.request({ + method: 'GET', + url: `/projects/${pid}/cost-surfaces`, + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + params, + }).then(({ data }) => mockData), + enabled: Boolean(pid), + ...queryOptions, + }); +} 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/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 f965c640e4..e1418daab0 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 @@ -4,7 +4,7 @@ export type DataItem = { id: string; name: string; scenarios: number; - tag: string; + tag?: string; isVisibleOnMap: boolean; isCustom: boolean; }; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx index 20c04506aa..d729ed87f6 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx @@ -1,5 +1,135 @@ +import { useState, useCallback, useEffect, ChangeEvent } from 'react'; + +import { useRouter } from 'next/router'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { setSelectedCostSurfaces as setVisibleCostSurfaces } from 'store/slices/projects/[id]'; + +import { useProjectCostSurfaces } from 'hooks/cost-surface'; + +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 { CostSurface } from 'types/api/cost-surface'; + +import InventoryTable, { type DataItem } from '../components/inventory-table'; + +const COST_SURFACE_TABLE_COLUMNS = { + name: 'Name', +}; + const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { - return
{noDataMessage}
; + const dispatch = useAppDispatch(); + const { selectedCostSurfaces: visibleCostSurfaces, 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.name, + }); + + const allProjectCostSurfacesQuery = useProjectCostSurfaces( + pid, + { + ...filters, + search, + }, + { + select: (data) => + data?.map((cs) => ({ + id: cs.id, + name: cs.name, + 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']) => { + const newSelectedCostSurfaces = [...visibleCostSurfaces]; + if (!newSelectedCostSurfaces.includes(costSurfaceId)) { + newSelectedCostSurfaces.push(costSurfaceId); + } else { + const i = newSelectedCostSurfaces.indexOf(costSurfaceId); + newSelectedCostSurfaces.splice(i, 1); + } + dispatch(setVisibleCostSurfaces(newSelectedCostSurfaces)); + }, + [dispatch, visibleCostSurfaces] + ); + + 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((wdpa) => ({ + ...wdpa, + name: wdpa.name, + scenarios: wdpa.scenarioUsageCount, + isVisibleOnMap: visibleCostSurfaces?.includes(wdpa.id), + })); + + return ( +
+ + {displayBulkActions && ( + + )} +
+ ); }; export default InventoryPanelCostSurface; 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..8e1098927c 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -7,13 +7,12 @@ 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', 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..924e96d1c1 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[]; + selectedCostSurfaces: string[]; isSidebarOpen: boolean; } @@ -15,6 +16,7 @@ const initialState: ProjectShowStateProps = { sort: '-lastModifiedAt', layerSettings: {}, selectedFeatures: [], + selectedCostSurfaces: [], isSidebarOpen: true, } satisfies ProjectShowStateProps; @@ -62,6 +64,13 @@ const projectsDetailSlice = createSlice({ ) => { state.selectedFeatures = action.payload; }, + // COST SURFACE + setSelectedCostSurfaces: ( + state, + action: PayloadAction + ) => { + state.selectedCostSurfaces = action.payload; + }, }, }); @@ -71,6 +80,7 @@ export const { setSort, setLayerSettings, setSelectedFeatures, + setSelectedCostSurfaces, 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..5fe2235785 --- /dev/null +++ b/app/types/api/cost-surface.ts @@ -0,0 +1,5 @@ +export interface CostSurface { + id: string; + name: string; + scenarioUsageCount: number; +} From 8129a3aaf89986c6fcdeebd370c31e9917ddbc83 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Tue, 29 Aug 2023 17:12:33 +0200 Subject: [PATCH 02/11] list styles --- app/hooks/cost-surface/index.ts | 3 +- .../components/inventory-table/index.tsx | 42 ++++++++++--------- .../inventory-table/row-item/index.tsx | 12 +++--- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts index b9f3c59e2f..1d3c852957 100644 --- a/app/hooks/cost-surface/index.ts +++ b/app/hooks/cost-surface/index.ts @@ -37,7 +37,8 @@ export function useProjectCostSurfaces( queryFn: async () => API.request({ method: 'GET', - url: `/projects/${pid}/cost-surfaces`, + // !TODO: change this to the correct endpoint + url: `/projects/${pid}/protected-areas`, headers: { Authorization: `Bearer ${session.accessToken}`, }, 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 3f167992c6..dbd519fa66 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 @@ -44,25 +44,29 @@ const InventoryTable = ({ onChange={onSelectAll} /> - - - - - - + {columns['name'] && ( + + + + )} + {columns['tag'] && ( + + + + )} 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 3e440a7d7d..91b4e61d21 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 @@ -20,7 +20,7 @@ const RowItem = ({ const { id, name, scenarios, tag, isVisibleOnMap, isCustom } = item; return ( - + - - {tag && ( + {tag && ( +
{tag}
- )} - - + + )} +
+
+ + 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-surface/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx similarity index 92% rename from app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index d729ed87f6..ccd27241ba 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -7,15 +7,18 @@ import { setSelectedCostSurfaces as setVisibleCostSurfaces } from 'store/slices/ import { useProjectCostSurfaces } from 'hooks/cost-surface'; +import CostSurfacesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu'; 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 { CostSurface } from 'types/api/cost-surface'; import InventoryTable, { type DataItem } from '../components/inventory-table'; -const COST_SURFACE_TABLE_COLUMNS = { - name: 'Name', -}; +const COST_SURFACE_TABLE_COLUMNS = [ + { + name: 'name', + text: 'Name', + }, +]; const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); @@ -28,7 +31,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } const [selectedCostSurfaceIds, setSelectedCostSurfaceIds] = useState([]); const [filters, setFilters] = useState[1]>({ - sort: COST_SURFACE_TABLE_COLUMNS.name, + sort: COST_SURFACE_TABLE_COLUMNS[0].name, }); const allProjectCostSurfacesQuery = useProjectCostSurfaces( @@ -126,7 +129,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } ActionsComponent={ActionsMenu} /> {displayBulkActions && ( - + )} ); diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx similarity index 100% rename from app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx 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..095c5bc7c4 --- /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 features only if they are not being used by any scenario. + const haveScenarioAssociated = selectedCostSurfaces.some(({ scenarioUsageCount }) => + Boolean(scenarioUsageCount) + ); + + const handleBulkDelete = useCallback(() => { + const deletableFeatureIds = selectedCostSurfaces.map(({ id }) => id); + + bulkDeleteCostSurfaceFromProject(pid, deletableFeatureIds, session) + .then(async () => { + await queryClient.invalidateQueries(['cost-surfaces', pid]); + + onDismiss(); + + addToast( + 'delete-bulk-project-cost-surfaces', + <> +

Success

+

The features 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..2596baaf40 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx @@ -0,0 +1,276 @@ +import React, { + ElementRef, + useCallback, + useRef, + InputHTMLAttributes, + useState, + useEffect, +} 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 { + useEditFeatureTag, + useEditFeature, + useProjectFeatures, + useDeleteFeatureTag, +} from 'hooks/features'; +import { useProjectTags } from 'hooks/projects'; +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 Icon from 'components/icon/component'; +import { Feature } from 'types/api/feature'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +export type FormValues = { featureClassName: Feature['featureClassName']; tag: Feature['tag'] }; + +const EditModal = ({ + featureId, + handleModal, +}: { + featureId: Feature['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 tagsSectionRef = useRef>(null); + + const [tagsMenuOpen, setTagsMenuOpen] = useState(false); + const [tagIsDone, setTagIsDone] = useState(false); + + const tagsQuery = useProjectTags(pid); + const featureQuery = useProjectFeatures(pid, featureId); + const editFeatureTagMutation = useEditFeatureTag(); + const deleteFeatureTagMutation = useDeleteFeatureTag(); + const editFeatureMutation = useEditFeature(); + + 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); + }; + }, []); + + const onEditSubmit = useCallback( + (values: FormValues) => { + const { featureClassName, tag } = values; + const editFeaturePromise = editFeatureMutation.mutateAsync({ + fid: featureId, + body: { + featureClassName, + }, + }); + + const editFeatureTagPromise = () => { + if (values.tag) { + return editFeatureTagMutation.mutateAsync({ + projectId: pid, + featureId, + data: { + tagName: tag, + }, + }); + } else { + return deleteFeatureTagMutation.mutateAsync({ + projectId: pid, + featureId, + }); + } + }; + + Promise.all([editFeaturePromise, editFeatureTagPromise()]) + .then(async () => { + await queryClient.invalidateQueries(['all-features', pid]); + handleModal('edit', false); + + addToast( + 'success-edit-feature', + <> +

Success!

+

Features edited

+ , + { + level: 'success', + } + ); + }) + .catch(() => { + addToast( + 'error-edit-feature', + <> +

Error!

+

It is not possible to edit this feature

+ , + { + level: 'error', + } + ); + }); + }, + [ + addToast, + deleteFeatureTagMutation, + editFeatureTagMutation, + editFeatureMutation, + featureId, + handleModal, + pid, + queryClient, + ] + ); + + const handleKeyPress = useCallback( + (event: Parameters['onKeyDown']>[0]) => { + if (event.key === 'Enter') { + setTagIsDone(true); + formRef.current.change('tag', event.currentTarget.value); + setTagsMenuOpen(false); + } + }, + [formRef] + ); + + return ( + + initialValues={{ + featureClassName: featureQuery.data?.[0]?.featureClassName, + tag: featureQuery.data?.[0]?.tag, + }} + ref={formRef} + onSubmit={onEditSubmit} + render={({ form, handleSubmit, values }) => { + formRef.current = form; + + return ( +
+
+

Edit feature

+ +
+ + name="featureClassName" + validate={composeValidators([{ presence: true }])} + > + {(fprops) => ( + + + + + + )} + +
+ +
+ name="tag"> + {(fprops) => ( + + + {(!values.tag || !tagIsDone) && ( +
+ { + setTagsMenuOpen(true); + form.change('tag', ''); + }} + onBlur={() => setTagIsDone(true)} + onKeyDown={handleKeyPress} + /> + + {tagsMenuOpen && ( +
+
Recent:
+
+ {tagsQuery.data?.map((tag) => ( + + ))} +
+
+ )} +
+ )} + + {values.tag && tagIsDone && ( +
+
+

{values.tag}

+
+ +
+ )} +
+ )} + +
+ +
+ + + +
+
+
+ ); + }} + /> + ); +}; + +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..af00b70443 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx @@ -0,0 +1,502 @@ +import React, { + ElementRef, + InputHTMLAttributes, + 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 { useUploadFeaturesCSV, useUploadFeaturesShapefile } from 'hooks/features'; +import { useDownloadShapefileTemplate } from 'hooks/projects'; +import { useProjectTags } 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 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 { cn } from 'utils/cn'; +import { bytesToMegabytes } from 'utils/units'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +export type FormValues = { + name: string; + file: File; + tag: Feature['tag']; +}; + +export const FeatureUploadModal = ({ + isOpen = false, + onDismiss, +}: { + isOpen?: boolean; + 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 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); + }; + }, []); + + 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(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, tag } = 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', + } + ); + }, + onSettled: () => { + setLoading(false); + }, + }; + + if (uploadMode === 'shapefile') { + uploadFeaturesShapefileMutation.mutate({ data, id: `${pid}` }, mutationResponse); + } + + if (uploadMode === 'csv') { + uploadFeaturesCSVMutation.mutate({ data, id: `${pid}` }, mutationResponse); + } + }, + [ + 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] + ); + + const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ + multiple: false, + maxSize: 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={{ + tag: '', + }} + ref={formRef} + onSubmit={onUploadSubmit} + render={({ form, handleSubmit, values }) => { + formRef.current = form; + + return ( +
+
+
+

Upload feature

+ + + +
+ + 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}

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

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

+ +

{`Recommended file size < ${bytesToMegabytes( + 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 FeatureUploadModal; 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 From 56f367d32f83ca7f8e20468eef384918975d4d24 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Thu, 31 Aug 2023 15:30:05 +0200 Subject: [PATCH 05/11] delete unique cost surface --- .../cost-surfaces/actions-menu/index.tsx | 97 ++++++++++++++++ .../inventory-panel/cost-surfaces/index.tsx | 2 +- .../cost-surfaces/modals/delete/index.tsx | 8 +- .../cost-surfaces/modals/edit/index.tsx | 104 +----------------- 4 files changed, 104 insertions(+), 107 deletions(-) create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/actions-menu/index.tsx diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/actions-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/actions-menu/index.tsx new file mode 100644 index 0000000000..a1180eecec --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/actions-menu/index.tsx @@ -0,0 +1,97 @@ +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/cost-surfaces/modals/delete'; +import EditModal from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/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; + + // item.isCustom && !item.scenarioUsageCount + 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/cost-surfaces/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index ccd27241ba..616cdb7ad5 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -7,8 +7,8 @@ import { setSelectedCostSurfaces as setVisibleCostSurfaces } from 'store/slices/ 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 ActionsMenu from 'layout/project/sidebar/project/inventory-panel/features/actions-menu'; import { CostSurface } from 'types/api/cost-surface'; import InventoryTable, { type DataItem } from '../components/inventory-table'; 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 index 095c5bc7c4..0fad5ab67a 100644 --- 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 @@ -39,15 +39,15 @@ const DeleteModal = ({ }, [allProjectCostSurfacesQuery.data, selectedCostSurfacesIds]); const costSurfaceNames = selectedCostSurfaces.map(({ name }) => name); - // ? the user will be able to delete the features only if they are not being used by any scenario. + // ? 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 deletableFeatureIds = selectedCostSurfaces.map(({ id }) => id); + const deletableCostSurfaceIds = selectedCostSurfaces.map(({ id }) => id); - bulkDeleteCostSurfaceFromProject(pid, deletableFeatureIds, session) + bulkDeleteCostSurfaceFromProject(pid, deletableCostSurfaceIds, session) .then(async () => { await queryClient.invalidateQueries(['cost-surfaces', pid]); @@ -57,7 +57,7 @@ const DeleteModal = ({ 'delete-bulk-project-cost-surfaces', <>

Success

-

The features were deleted successfully.

+

The cost surfaces were deleted successfully.

, { level: 'success', 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 2596baaf40..4ae20ca706 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 @@ -45,29 +45,12 @@ const EditModal = ({ const { pid } = query as { pid: string }; const formRef = useRef['form']>(null); - const tagsSectionRef = useRef>(null); - const [tagsMenuOpen, setTagsMenuOpen] = useState(false); - const [tagIsDone, setTagIsDone] = useState(false); - - const tagsQuery = useProjectTags(pid); const featureQuery = useProjectFeatures(pid, featureId); const editFeatureTagMutation = useEditFeatureTag(); const deleteFeatureTagMutation = useDeleteFeatureTag(); const editFeatureMutation = useEditFeature(); - 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); - }; - }, []); - const onEditSubmit = useCallback( (values: FormValues) => { const { featureClassName, tag } = values; @@ -136,32 +119,20 @@ const EditModal = ({ ] ); - const handleKeyPress = useCallback( - (event: Parameters['onKeyDown']>[0]) => { - if (event.key === 'Enter') { - setTagIsDone(true); - formRef.current.change('tag', event.currentTarget.value); - setTagsMenuOpen(false); - } - }, - [formRef] - ); - return ( initialValues={{ featureClassName: featureQuery.data?.[0]?.featureClassName, - tag: featureQuery.data?.[0]?.tag, }} ref={formRef} onSubmit={onEditSubmit} - render={({ form, handleSubmit, values }) => { + render={({ form, handleSubmit }) => { formRef.current = form; return (
-

Edit feature

+

Edit cost surface

@@ -185,77 +156,6 @@ const EditModal = ({
-
- name="tag"> - {(fprops) => ( - - - {(!values.tag || !tagIsDone) && ( -
- { - setTagsMenuOpen(true); - form.change('tag', ''); - }} - onBlur={() => setTagIsDone(true)} - onKeyDown={handleKeyPress} - /> - - {tagsMenuOpen && ( -
-
Recent:
-
- {tagsQuery.data?.map((tag) => ( - - ))} -
-
- )} -
- )} - - {values.tag && tagIsDone && ( -
-
-

{values.tag}

-
- -
- )} -
- )} - -
-
void; }): JSX.Element => { const queryClient = useQueryClient(); @@ -46,83 +30,59 @@ const EditModal = ({ const formRef = useRef['form']>(null); - const featureQuery = useProjectFeatures(pid, featureId); - const editFeatureTagMutation = useEditFeatureTag(); - const deleteFeatureTagMutation = useDeleteFeatureTag(); - const editFeatureMutation = useEditFeature(); + const allProjectCostSurfacesQuery = useProjectCostSurfaces(pid, {}); + + const editCostSurfaceMutation = useEditCostSurface(); const onEditSubmit = useCallback( (values: FormValues) => { - const { featureClassName, tag } = values; - const editFeaturePromise = editFeatureMutation.mutateAsync({ - fid: featureId, - body: { - featureClassName, + const { name } = values; + + editCostSurfaceMutation.mutate( + { + costSurfaceId, + projectId: pid, + body: { + name, + }, }, - }); - - const editFeatureTagPromise = () => { - if (values.tag) { - return editFeatureTagMutation.mutateAsync({ - projectId: pid, - featureId, - data: { - tagName: tag, - }, - }); - } else { - return deleteFeatureTagMutation.mutateAsync({ - projectId: pid, - featureId, - }); + { + 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', + } + ); + }, } - }; - - Promise.all([editFeaturePromise, editFeatureTagPromise()]) - .then(async () => { - await queryClient.invalidateQueries(['all-features', pid]); - handleModal('edit', false); - - addToast( - 'success-edit-feature', - <> -

Success!

-

Features edited

- , - { - level: 'success', - } - ); - }) - .catch(() => { - addToast( - 'error-edit-feature', - <> -

Error!

-

It is not possible to edit this feature

- , - { - level: 'error', - } - ); - }); + ); }, - [ - addToast, - deleteFeatureTagMutation, - editFeatureTagMutation, - editFeatureMutation, - featureId, - handleModal, - pid, - queryClient, - ] + [addToast, costSurfaceId, editCostSurfaceMutation, handleModal, pid, queryClient] ); return ( initialValues={{ - featureClassName: featureQuery.data?.[0]?.featureClassName, + name: allProjectCostSurfacesQuery.data?.[0]?.name, }} ref={formRef} onSubmit={onEditSubmit} @@ -135,12 +95,9 @@ const EditModal = ({

Edit cost surface

- - name="featureClassName" - validate={composeValidators([{ presence: true }])} - > + name="name" validate={composeValidators([{ presence: true }])}> {(fprops) => ( - + From c7f571fefbd07c8a23d19a0f1da4ce15da087a08 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Fri, 1 Sep 2023 09:46:38 +0200 Subject: [PATCH 07/11] upload shapefile --- app/hooks/cost-surface/index.ts | 25 +- app/layout/info/upload-cost-surface.tsx | 10 + .../info}/upload-features.tsx | 0 .../components/inventory-table/types.ts | 2 +- .../project/inventory-panel/constants.ts | 2 + .../inventory-panel/cost-surfaces/index.tsx | 12 +- .../cost-surfaces/info/index.tsx | 46 +-- .../cost-surfaces/modals/edit/index.tsx | 8 +- .../cost-surfaces/modals/upload/index.tsx | 324 +++++------------- .../features/modals/upload/index.tsx | 2 +- .../add/add-modal/uploader/component.tsx | 2 +- app/types/api/cost-surface.ts | 1 + 12 files changed, 165 insertions(+), 269 deletions(-) create mode 100644 app/layout/info/upload-cost-surface.tsx rename app/{constants/info-button-content => layout/info}/upload-features.tsx (100%) diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts index fbc6cb9143..e69927f0ed 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'], @@ -18,16 +19,19 @@ export function useProjectCostSurfaces( { id: 'gfehrtf22534geyg', name: 'Cost Surface Rwanda A', + 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, }, ]; @@ -49,7 +53,7 @@ export function useProjectCostSurfaces( }); } -export function useEditCostSurface() { +export function useEditProjectCostSurface() { const { data: session } = useSession(); const editCostSurface = ({ @@ -77,3 +81,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/components/inventory-table/types.ts b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts index 87b464baed..55942fbe87 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 @@ -6,7 +6,7 @@ export type DataItem = { scenarios: number; tag?: string; isVisibleOnMap: boolean; - isCustom: boolean; + isCustom?: boolean; }; export type InventoryTable = { 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/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index 616cdb7ad5..003b2d1fd1 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -45,6 +45,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } data?.map((cs) => ({ id: cs.id, name: cs.name, + isCustom: cs.isCustom, scenarioUsageCount: cs.scenarioUsageCount, })), keepPreviousData: true, @@ -106,11 +107,12 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } const displayBulkActions = selectedCostSurfaceIds.length > 0; - const data: DataItem[] = allProjectCostSurfacesQuery.data?.map((wdpa) => ({ - ...wdpa, - name: wdpa.name, - scenarios: wdpa.scenarioUsageCount, - isVisibleOnMap: visibleCostSurfaces?.includes(wdpa.id), + const data: DataItem[] = allProjectCostSurfacesQuery.data?.map((cs) => ({ + ...cs, + name: cs.name, + scenarios: cs.scenarioUsageCount, + isCustom: cs.isCustom, + isVisibleOnMap: visibleCostSurfaces?.includes(cs.id), })); return ( 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..370c17b73e 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`}

Date: Fri, 1 Sep 2023 15:31:56 +0200 Subject: [PATCH 08/11] filters --- app/hooks/cost-surface/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts index e69927f0ed..b923449722 100644 --- a/app/hooks/cost-surface/index.ts +++ b/app/hooks/cost-surface/index.ts @@ -10,7 +10,7 @@ import UPLOADS from 'services/uploads'; export function useProjectCostSurfaces( pid: Project['id'], - params: { search?: string; sort?: string; filters?: Record } = {}, + params: { search?: string; sort?: string } = {}, queryOptions: QueryObserverOptions = {} ) { const { data: session } = useSession(); From 6554371e42de439fdce864fde5a0dd58fb20b924 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Fri, 1 Sep 2023 16:05:58 +0200 Subject: [PATCH 09/11] map --- app/hooks/cost-surface/index.ts | 4 ++-- app/layout/projects/show/map/index.tsx | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/hooks/cost-surface/index.ts b/app/hooks/cost-surface/index.ts index b923449722..bfca3f3cd1 100644 --- a/app/hooks/cost-surface/index.ts +++ b/app/hooks/cost-surface/index.ts @@ -17,8 +17,8 @@ export function useProjectCostSurfaces( const mockData: CostSurface[] = [ { - id: 'gfehrtf22534geyg', - name: 'Cost Surface Rwanda A', + id: 'b7454579-c48e-4e2f-8438-833280cb65d8', + name: 'Brazil 15 k Cost Surface', isCustom: true, scenarioUsageCount: 3, }, diff --git a/app/layout/projects/show/map/index.tsx b/app/layout/projects/show/map/index.tsx index dcd9087deb..91d42990bd 100644 --- a/app/layout/projects/show/map/index.tsx +++ b/app/layout/projects/show/map/index.tsx @@ -52,6 +52,7 @@ export const ProjectMap = (): JSX.Element => { isSidebarOpen, layerSettings, selectedFeatures: selectedFeaturesIds, + selectedCostSurfaces: selectedCostSurfacesIds, } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); @@ -106,19 +107,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'] : []), + ...(!!selectedCostSurfacesIds.length ? ['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, }, }, }); @@ -159,6 +160,7 @@ export const ProjectMap = (): JSX.Element => { const LEGEND = useLegend({ layers: [ ...(!!selectedFeaturesData?.length ? ['features-preview'] : []), + ...(!!selectedCostSurfacesIds?.length ? ['cost'] : []), ...(!!sid1 && !sid2 ? ['frequency'] : []), ...(!!sid1 && !!sid2 ? ['compare'] : []), @@ -168,6 +170,7 @@ export const ProjectMap = (): JSX.Element => { ], options: { layerSettings, + cost: { min: 1, max: 100 }, items: selectedPreviewFeatures, }, }); From 2cdb0ed4f621f994ae1a50afedd639d6ce24f027 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Mon, 4 Sep 2023 11:46:04 +0200 Subject: [PATCH 10/11] select one cost surface to show on map and display name on legend --- app/hooks/map/constants.tsx | 2 +- app/hooks/map/types.ts | 1 + .../inventory-panel/cost-surfaces/index.tsx | 17 ++++++------- app/layout/projects/show/map/index.tsx | 25 ++++++++++++++++--- app/store/slices/projects/[id].ts | 12 ++++----- 5 files changed, 36 insertions(+), 21 deletions(-) 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/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index 003b2d1fd1..9f33a37025 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, ChangeEvent } from 'react'; import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; -import { setSelectedCostSurfaces as setVisibleCostSurfaces } from 'store/slices/projects/[id]'; +import { setSelectedCostSurface as setVisibleCostSurface } from 'store/slices/projects/[id]'; import { useProjectCostSurfaces } from 'hooks/cost-surface'; @@ -22,7 +22,7 @@ const COST_SURFACE_TABLE_COLUMNS = [ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); - const { selectedCostSurfaces: visibleCostSurfaces, search } = useAppSelector( + const { selectedCostSurface: visibleCostSurface, search } = useAppSelector( (state) => state['/projects/[id]'] ); @@ -81,16 +81,13 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } const toggleSeeOnMap = useCallback( (costSurfaceId: CostSurface['id']) => { - const newSelectedCostSurfaces = [...visibleCostSurfaces]; - if (!newSelectedCostSurfaces.includes(costSurfaceId)) { - newSelectedCostSurfaces.push(costSurfaceId); + if (costSurfaceId === visibleCostSurface) { + dispatch(setVisibleCostSurface(null)); } else { - const i = newSelectedCostSurfaces.indexOf(costSurfaceId); - newSelectedCostSurfaces.splice(i, 1); + dispatch(setVisibleCostSurface(costSurfaceId)); } - dispatch(setVisibleCostSurfaces(newSelectedCostSurfaces)); }, - [dispatch, visibleCostSurfaces] + [dispatch, visibleCostSurface] ); const handleSort = useCallback( @@ -112,7 +109,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } name: cs.name, scenarios: cs.scenarioUsageCount, isCustom: cs.isCustom, - isVisibleOnMap: visibleCostSurfaces?.includes(cs.id), + isVisibleOnMap: visibleCostSurface === cs.id, })); return ( diff --git a/app/layout/projects/show/map/index.tsx b/app/layout/projects/show/map/index.tsx index 91d42990bd..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,7 +53,7 @@ export const ProjectMap = (): JSX.Element => { isSidebarOpen, layerSettings, selectedFeatures: selectedFeaturesIds, - selectedCostSurfaces: selectedCostSurfacesIds, + selectedCostSurface: selectedCostSurfaceId, } = useAppSelector((state) => state['/projects/[id]']); const accessToken = useAccessToken(); @@ -110,7 +111,7 @@ export const ProjectMap = (): JSX.Element => { include: 'results,cost', sublayers: [ ...(sid1 && !sid2 ? ['frequency'] : []), - ...(!!selectedCostSurfacesIds.length ? ['cost'] : []), + ...(!!selectedCostSurfaceId ? ['cost'] : []), ], options: { cost: { min: 1, max: 100 }, @@ -153,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 ); @@ -160,7 +177,7 @@ export const ProjectMap = (): JSX.Element => { const LEGEND = useLegend({ layers: [ ...(!!selectedFeaturesData?.length ? ['features-preview'] : []), - ...(!!selectedCostSurfacesIds?.length ? ['cost'] : []), + ...(!!selectedCostSurfaceId ? ['cost'] : []), ...(!!sid1 && !sid2 ? ['frequency'] : []), ...(!!sid1 && !!sid2 ? ['compare'] : []), @@ -170,7 +187,7 @@ export const ProjectMap = (): JSX.Element => { ], options: { layerSettings, - cost: { min: 1, max: 100 }, + cost: { name: allProjectCostSurfacesQuery.data?.name, min: 1, max: 100 }, items: selectedPreviewFeatures, }, }); diff --git a/app/store/slices/projects/[id].ts b/app/store/slices/projects/[id].ts index 924e96d1c1..06bb3d6cba 100644 --- a/app/store/slices/projects/[id].ts +++ b/app/store/slices/projects/[id].ts @@ -6,7 +6,7 @@ interface ProjectShowStateProps { sort: string; layerSettings: Record; selectedFeatures: string[]; - selectedCostSurfaces: string[]; + selectedCostSurface: string | null; isSidebarOpen: boolean; } @@ -16,7 +16,7 @@ const initialState: ProjectShowStateProps = { sort: '-lastModifiedAt', layerSettings: {}, selectedFeatures: [], - selectedCostSurfaces: [], + selectedCostSurface: null, isSidebarOpen: true, } satisfies ProjectShowStateProps; @@ -65,11 +65,11 @@ const projectsDetailSlice = createSlice({ state.selectedFeatures = action.payload; }, // COST SURFACE - setSelectedCostSurfaces: ( + setSelectedCostSurface: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.selectedCostSurfaces = action.payload; + state.selectedCostSurface = action.payload; }, }, }); @@ -80,7 +80,7 @@ export const { setSort, setLayerSettings, setSelectedFeatures, - setSelectedCostSurfaces, + setSelectedCostSurface, setSidebarVisibility, } = projectsDetailSlice.actions; From 479da4ce7ea8909796e5bb3fe64ce5e0cea9ff84 Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Mon, 11 Sep 2023 12:59:52 +0200 Subject: [PATCH 11/11] minor styles --- .../cost-surfaces/bulk-action-menu/index.tsx | 1 - .../inventory-panel/cost-surfaces/index.tsx | 30 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) 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 index 4335d77d22..a196227b7e 100644 --- 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 @@ -6,7 +6,6 @@ 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 EDIT_SVG from 'svgs/ui/edit.svg?sprite'; import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; const BUTTON_CLASSES = 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 index 9f33a37025..7fc338cc86 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -113,20 +113,22 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } })); return ( -
- +
+
+ +
{displayBulkActions && ( )}