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 b89b6d68c8..17badb827a 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; }; 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 2e5f5967a2..bdbeff1d0d 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; +}