diff --git a/app/layout/project/navigation/types.ts b/app/layout/project/navigation/types.ts index 325b538052..1e8a58aaa3 100644 --- a/app/layout/project/navigation/types.ts +++ b/app/layout/project/navigation/types.ts @@ -4,3 +4,5 @@ export type NavigationTreeCategories = | 'gridSetup' | 'solutions' | 'advancedSettings'; + +export type NavigationInventoryTabs = 'protected-areas' | 'cost-surface' | 'features'; 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 new file mode 100644 index 0000000000..45285a4b0c --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/index.tsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { ArrowDown, ArrowUp } from 'lucide-react'; + +import { cn } from 'utils/cn'; + +import { HeaderItem } from './types'; + +const HeaderItem = ({ + className, + text, + name, + columns, + 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 handleClick = useCallback(() => { + onClick(columns[name]); + }, [onClick, columns, name]); + + return ( + + ); +}; + +export default HeaderItem; diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/types.ts b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/types.ts new file mode 100644 index 0000000000..b550e8447d --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/header-item/types.ts @@ -0,0 +1,10 @@ +export type HeaderItem = { + className?: string; + text: string; + name: string; + columns: { + [key: string]: string; + }; + sorting: string; + onClick?: (field: string) => void; +}; 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 new file mode 100644 index 0000000000..906bb73aa3 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx @@ -0,0 +1,87 @@ +import Checkbox from 'components/forms/checkbox'; +import Loading from 'components/loading'; + +import HeaderItem from './header-item'; +import RowItem from './row-item'; +import { InventoryTable } from './types'; + +const InventoryTable = ({ + loading, + data, + noDataMessage, + columns, + sorting, + selectedIds, + onSortChange, + onToggleSeeOnMap, + onSelectRow, + onSelectAll, + ActionsComponent, +}: InventoryTable): JSX.Element => { + const noData = !loading && data?.length === 0; + + return ( + <> + {loading && !data.length && ( +
+ +
+ )} + {noData &&
{noDataMessage}
} + {!!data?.length && ( + + + + + + + + + + {data.map((item) => ( + + ))} + +
+ + + + + +
+ )} + + ); +}; + +export { type DataItem } from './types'; + +export default InventoryTable; 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 new file mode 100644 index 0000000000..9facd7eb38 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/index.tsx @@ -0,0 +1,85 @@ +import { MoreHorizontal } from 'lucide-react'; + +import Checkbox from 'components/forms/checkbox'; +import Icon from 'components/icon'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/popover'; +import { cn } from 'utils/cn'; + +import HIDE_SVG from 'svgs/ui/hide.svg?sprite'; +import SHOW_SVG from 'svgs/ui/show.svg?sprite'; + +import { RowItem } from './types'; + +const RowItem = ({ + item, + selectedIds, + onSelectRow, + onToggleSeeOnMap, + ActionsComponent, +}: RowItem) => { + const { id, name, scenarios, tag, isVisibleOnMap } = item; + + return ( + + + + + + {name} +
+ Currently in use in + + {scenarios} + {' '} + scenarios. +
+ + + {tag && ( +
+ + {tag} + +
+ )} + + +
+ + + + + + + + + +
+ + + ); +}; + +export default RowItem; diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/types.ts b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/types.ts new file mode 100644 index 0000000000..350ee574ec --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/row-item/types.ts @@ -0,0 +1,11 @@ +import { ChangeEvent } from 'react'; + +import { DataItem } from '../types'; + +export type RowItem = { + item: DataItem; + selectedIds: string[]; + onSelectRow: (evt: ChangeEvent) => void; + onToggleSeeOnMap: (id: string) => void; + ActionsComponent: ({ item }) => JSX.Element; +}; 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 new file mode 100644 index 0000000000..b89b6d68c8 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/types.ts @@ -0,0 +1,25 @@ +import { ChangeEvent } from 'react'; + +export type DataItem = { + id: string; + name: string; + scenarios: number; + tag: string; + isVisibleOnMap: boolean; +}; + +export type InventoryTable = { + loading: boolean; + data: DataItem[]; + noDataMessage: string; + columns: { + [key: string]: string; + }; + sorting: string; + selectedIds: string[]; + onSortChange: (field: string) => void; + onToggleSeeOnMap: (id: string) => void; + onSelectRow: (evt: ChangeEvent) => void; + onSelectAll: (evt: ChangeEvent) => void; + ActionsComponent: ({ item }) => JSX.Element; +}; diff --git a/app/layout/project/sidebar/project/inventory-panel/constants.ts b/app/layout/project/sidebar/project/inventory-panel/constants.ts new file mode 100644 index 0000000000..7acacc1f3d --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/constants.ts @@ -0,0 +1,37 @@ +import { NavigationInventoryTabs } from 'layout/project/navigation/types'; + +import CostSurfaceTable from './cost-surface'; +import CostSurfaceInfo from './cost-surface/info'; +import FeaturesTable from './features'; +import FeaturesInfo from './features/info'; +import FeatureUploadModal from './features/modals/upload'; +import ProtectedAreasTable from './protected-areas'; +import ProtectedAreasFooter from './protected-areas/footer'; +import { InventoryPanel } from './types'; + +export const INVENTORY_TABS = { + 'protected-areas': { + title: 'Protected Areas', + search: 'Search protected areas', + noData: 'No protected areas found.', + TableComponent: ProtectedAreasTable, + FooterComponent: ProtectedAreasFooter, + }, + 'cost-surface': { + title: 'Cost Surface', + search: 'Search cost surfaces', + noData: 'No cost surfaces found.', + InfoComponent: CostSurfaceInfo, + TableComponent: CostSurfaceTable, + }, + features: { + title: 'Features', + search: 'Search features', + noData: 'No features found.', + InfoComponent: FeaturesInfo, + UploadModalComponent: FeatureUploadModal, + TableComponent: FeaturesTable, + }, +} satisfies { + [key in NavigationInventoryTabs]: InventoryPanel; +}; 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 e54c0cc7bb..20c04506aa 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,7 +1,5 @@ -import Section from 'layout/section'; - -const InventoryPanelCostSurface = (): JSX.Element => { - return
InventoryPanelCostSurface
; +const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { + return
{noDataMessage}
; }; export default InventoryPanelCostSurface; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx new file mode 100644 index 0000000000..6c5bc50ae0 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx @@ -0,0 +1,28 @@ +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 +
+ + ); +}; + +export default CostSurfaceInfo; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/list/item/actions/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/actions-menu/index.tsx similarity index 65% rename from app/layout/project/sidebar/project/inventory-panel/features/list/item/actions/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/features/actions-menu/index.tsx index c64032733a..9d5cf5c54d 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/list/item/actions/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/actions-menu/index.tsx @@ -1,27 +1,33 @@ -import { useCallback, useState, ButtonHTMLAttributes } from 'react'; - -import { FileEdit, Trash2, Tag } from 'lucide-react'; +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/features/modals/delete'; import EditModal from 'layout/project/sidebar/project/inventory-panel/features/modals/edit'; -import { Feature } from 'types/api/feature'; 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 = 'text-gray-400 group-hover:text-white'; +const ICON_CLASSES = 'h-5 w-5 text-gray-400 group-hover:text-white'; -const FeatureActions = ({ - feature, - onEditName, - isDeletable, +const ActionsMenu = ({ + item, }: { - feature: Feature; - isDeletable: boolean; - onEditName: (evt: Parameters['onClick']>[0]) => void; + 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, @@ -33,39 +39,27 @@ const FeatureActions = ({ return (
    -
  • - -
  • handleModal('edit', false)} > - +
  • {isDeletable && ( @@ -80,7 +74,7 @@ const FeatureActions = ({ 'rounded-b-2xl': true, })} > - + Delete - + )} @@ -100,4 +94,4 @@ const FeatureActions = ({ ); }; -export default FeatureActions; +export default ActionsMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/index.tsx index b692fed2f6..ab35ef62c6 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/index.tsx @@ -7,7 +7,7 @@ import DeleteModal from 'layout/project/sidebar/project/inventory-panel/features import EditBulkModal from 'layout/project/sidebar/project/inventory-panel/features/modals/edit-bulk'; import { Feature } from 'types/api/feature'; -import EDIT_SVG from 'svgs/project/edit.svg?sprite'; +import EDIT_SVG from 'svgs/ui/edit.svg?sprite'; import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; const BUTTON_CLASSES = @@ -30,7 +30,7 @@ const FeaturesBulkActionMenu = ({ return ( <> -
    +
    {selectedFeaturesIds.length} 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 e032a977ed..2e5f5967a2 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/index.tsx @@ -1,87 +1,131 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, ChangeEvent, useEffect } from 'react'; -import { useAppDispatch } from 'store/hooks'; -import { setSearch } from 'store/slices/projects/[id]'; +import { useRouter } from 'next/router'; -import Button from 'components/button'; -import Icon from 'components/icon'; -import InfoButton from 'components/info-button'; -import Search, { SearchProps } from 'components/search'; -import Section from 'layout/section'; +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { setSelectedFeatures as setVisibleFeatures } from 'store/slices/projects/[id]'; -import FEATURE_ABUND_IMG from 'images/info-buttons/img_abundance_data.png'; -import FEATURE_SOCIAL_IMG from 'images/info-buttons/img_social_uses.png'; -import FEATURE_SPECIES_IMG from 'images/info-buttons/img_species_range.png'; +import { useAllFeatures } from 'hooks/features'; -import UPLOAD_SVG from 'svgs/ui/upload.svg?sprite'; +import { Feature } from 'types/api/feature'; -import ProjectFeatureList from './list'; -import FeatureUploadModal from './modals/upload'; +import InventoryTable, { type DataItem } from '../components/inventory-table'; -const InventoryPanelFeatures = (): JSX.Element => { +import ActionsMenu from './actions-menu'; +import FeaturesBulkActionMenu from './bulk-action-menu'; + +const FEATURES_TABLE_COLUMNS = { + name: 'featureClassName', + tag: 'tag', +}; + +const InventoryPanelFeatures = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); - const handleSearch = useCallback( - (value: Parameters[0]) => { - dispatch(setSearch(value)); + const { selectedFeatures: visibleFeatures, search } = useAppSelector( + (state) => state['/projects/[id]'] + ); + + const [filters, setFilters] = useState[1]>({ + sort: 'featureClassName', + }); + const [selectedFeaturesIds, setSelectedFeaturesIds] = useState([]); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const allFeaturesQuery = useAllFeatures( + pid, + { + ...filters, + search, }, - [dispatch] + { + select: ({ data }) => + data?.map((feature) => ({ + id: feature.id, + name: feature.featureClassName, + scenarios: feature.scenarioUsageCount, + tag: feature.tag, + isCustom: feature.isCustom, + })), + placeholderData: { data: [] }, + keepPreviousData: true, + } ); - const [isOpenFeatureUploader, setOpenFeatureUploader] = useState(false); - const handleFeatureUploader = useCallback(() => { - setOpenFeatureUploader(true); - }, []); + const featureIds = allFeaturesQuery.data?.map((feature) => feature.id); + + const handleSelectAll = useCallback( + (evt: ChangeEvent) => { + setSelectedFeaturesIds(evt.target.checked ? featureIds : []); + }, + [featureIds] + ); - const closeFeatureUploadModal = useCallback(() => { - setOpenFeatureUploader(false); + const handleSelectFeature = useCallback((evt: ChangeEvent) => { + if (evt.target.checked) { + setSelectedFeaturesIds((prevSelectedFeatures) => [...prevSelectedFeatures, evt.target.value]); + } else { + setSelectedFeaturesIds((prevSelectedFeatures) => + prevSelectedFeatures.filter((featureId) => featureId !== evt.target.value) + ); + } }, []); + const handleSort = useCallback( + (_sortType: (typeof filters)['sort']) => { + const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; + + setFilters((prevFilters) => ({ + ...prevFilters, + sort, + })); + }, + [filters.sort] + ); + + useEffect(() => { + setSelectedFeaturesIds([]); + }, [search]); + + const toggleSeeOnMap = useCallback( + (featureId: Feature['id']) => { + const newSelectedFeatures = [...visibleFeatures]; + if (!newSelectedFeatures.includes(featureId)) { + newSelectedFeatures.push(featureId); + } else { + const i = newSelectedFeatures.indexOf(featureId); + newSelectedFeatures.splice(i, 1); + } + dispatch(setVisibleFeatures(newSelectedFeatures)); + }, + [dispatch, visibleFeatures] + ); + + const displayBulkActions = selectedFeaturesIds.length > 0; + + const data: DataItem[] = allFeaturesQuery.data?.map((feature) => ({ + ...feature, + isVisibleOnMap: visibleFeatures.includes(feature.id), + })); + return ( -
    -
    -
    - Inventory Panel -

    - Features - - <> -

    What are features?

    -
    -

    - Features are the important habitats, species, processes, activities, and - discrete areas that you want to consider in your planning process. Common - feature data formats are range maps, polygons, abundances, and continuous scale - or probability of occurrence maps (e.g. 0-1). Features can include more than - just ecological data but also be cultural and socio-economic areas like - community fishing grounds or traditional-use areas, and other human activities - and industries. Every feature must have a minimum target amount set. Some - examples include: -

    - Feature-Range - Feature-Abundance - Feature-Social -
    - -
    -

    -
    - -
    - + - - -
    + {displayBulkActions && } +
    ); }; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/info/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/info/index.tsx new file mode 100644 index 0000000000..2b3160e600 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/features/info/index.tsx @@ -0,0 +1,27 @@ +import FEATURE_ABUND_IMG from 'images/info-buttons/img_abundance_data.png'; +import FEATURE_SOCIAL_IMG from 'images/info-buttons/img_social_uses.png'; +import FEATURE_SPECIES_IMG from 'images/info-buttons/img_species_range.png'; + +const FeaturesInfo = (): JSX.Element => { + return ( + <> +

    What are features?

    +
    +

    + Features are the important habitats, species, processes, activities, and discrete areas + that you want to consider in your planning process. Common feature data formats are range + maps, polygons, abundances, and continuous scale or probability of occurrence maps (e.g. + 0-1). Features can include more than just ecological data but also be cultural and + socio-economic areas like community fishing grounds or traditional-use areas, and other + human activities and industries. Every feature must have a minimum target amount set. Some + examples include: +

    + Feature-Range + Feature-Abundance + Feature-Social +
    + + ); +}; + +export default FeaturesInfo; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/list/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/list/index.tsx deleted file mode 100644 index 34218148fa..0000000000 --- a/app/layout/project/sidebar/project/inventory-panel/features/list/index.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useCallback, useState, ChangeEvent, useEffect } from 'react'; - -import { useRouter } from 'next/router'; - -import { useAppDispatch, useAppSelector } from 'store/hooks'; -import { setSelectedFeatures as setVisibleFeatures } from 'store/slices/projects/[id]'; - -import { ArrowDown, ArrowUp } from 'lucide-react'; - -import { useAllFeatures } from 'hooks/features'; - -import Checkbox from 'components/forms/checkbox'; -import Loading from 'components/loading'; -import { Feature } from 'types/api/feature'; -import { cn } from 'utils/cn'; - -import FeaturesBulkActionMenu from '../bulk-action-menu'; - -import FeatureItemList from './item'; - -export const ProjectFeatureList = (): JSX.Element => { - const dispatch = useAppDispatch(); - const { selectedFeatures: visibleFeatures, search } = useAppSelector( - (state) => state['/projects/[id]'] - ); - - const [filters, setFilters] = useState[1]>({ - sort: 'featureClassName', - }); - const [selectedFeaturesIds, setSelectedFeaturesIds] = useState([]); - const { query } = useRouter(); - const { pid } = query as { pid: string }; - const allFeaturesQuery = useAllFeatures( - pid, - { - ...filters, - search, - }, - { - select: ({ data }) => data, - placeholderData: { data: [] }, - } - ); - const featureIds = allFeaturesQuery.data?.map((feature) => feature.id); - - const handleSelectAll = useCallback( - (evt: ChangeEvent) => { - setSelectedFeaturesIds(evt.target.checked ? featureIds : []); - }, - [featureIds] - ); - - const handleSelectFeature = useCallback((evt: ChangeEvent) => { - if (evt.target.checked) { - setSelectedFeaturesIds((prevSelectedFeatures) => [...prevSelectedFeatures, evt.target.value]); - } else { - setSelectedFeaturesIds((prevSelectedFeatures) => - prevSelectedFeatures.filter((featureId) => featureId !== evt.target.value) - ); - } - }, []); - - const handleSort = useCallback( - (_sortType: (typeof filters)['sort']) => { - const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; - - setFilters((prevFilters) => ({ - ...prevFilters, - sort, - })); - }, - [filters.sort] - ); - - const toggleSeeOnMap = useCallback( - (featureId: Feature['id']) => { - const newSelectedFeatures = [...visibleFeatures]; - - if (!newSelectedFeatures.includes(featureId)) { - newSelectedFeatures.push(featureId); - } else { - const i = newSelectedFeatures.indexOf(featureId); - newSelectedFeatures.splice(i, 1); - } - dispatch(setVisibleFeatures(newSelectedFeatures)); - }, - [dispatch, visibleFeatures] - ); - - useEffect(() => { - setSelectedFeaturesIds([]); - }, [search]); - - return ( -
    -
    -
    - - -
    -
    - -
    -
    -
    - {allFeaturesQuery.isFetching && ( -
    - -
    - )} - - {!allFeaturesQuery.data?.length && allFeaturesQuery.isFetching === false && ( -
    No features found.
    - )} - {!allFeaturesQuery.isFetching && ( -
    0, - })} - > -
      - {allFeaturesQuery.data?.map((feature) => ( -
    • - toggleSeeOnMap(feature.id)} - isShown={visibleFeatures.includes(feature.id)} - /> -
    • - ))} -
    - - {selectedFeaturesIds.length > 0 && ( - - )} -
    - )} -
    -
    - ); -}; - -export default ProjectFeatureList; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/list/item/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/list/item/index.tsx deleted file mode 100644 index a7ad4068fc..0000000000 --- a/app/layout/project/sidebar/project/inventory-panel/features/list/item/index.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useCallback, useState, ChangeEvent, useRef, InputHTMLAttributes } from 'react'; - -import { useQueryClient } from 'react-query'; - -import { MoreHorizontal } from 'lucide-react'; - -import { useEditFeature } from 'hooks/features'; -import { useToasts } from 'hooks/toast'; - -import Checkbox from 'components/forms/checkbox'; -import Icon from 'components/icon'; -import { Popover, PopoverContent, PopoverTrigger } from 'components/popover'; -import Tag from 'components/tag'; -import { Feature } from 'types/api/feature'; -import { Project } from 'types/api/project'; -import { cn } from 'utils/cn'; - -import HIDE_SVG from 'svgs/ui/hide.svg?sprite'; -import SHOW_SVG from 'svgs/ui/show.svg?sprite'; - -import FeatureActions from './actions'; - -const FeatureItemList = ({ - feature, - projectId, - isSelected, - onSelectFeature, - toggleSeeOnMap, - isShown, -}: { - feature: Feature; - projectId: Project['id']; - isSelected: boolean; - onSelectFeature: (evt: ChangeEvent) => void; - toggleSeeOnMap: () => void; - isShown: boolean; -}): JSX.Element => { - const queryClient = useQueryClient(); - const { addToast } = useToasts(); - - const [isEditable, setEditable] = useState(false); - const nameInputRef = useRef(null); - const { mutate: editFeature } = useEditFeature(); - - const handleRename = useCallback(() => { - setEditable(true); - nameInputRef.current?.focus(); - }, [nameInputRef]); - - const handleNameChanges = useCallback( - (evt: Parameters['onKeyDown']>[0]) => { - if (evt.key === 'Enter') { - setEditable(false); - nameInputRef.current?.blur(); - - editFeature( - { - fid: feature.id, - body: { - featureClassName: evt.currentTarget.value, - }, - }, - { - onSuccess: async () => { - await queryClient.invalidateQueries(['all-features', projectId]); - - addToast( - 'edit-project-features', - <> -

    Success

    -

    The feature was updated successfully.

    - , - { - level: 'success', - } - ); - }, - onError: () => { - addToast( - 'edit-project-features', - <> -

    Error

    -

    Something went wrong editing the feature.

    - , - { - level: 'error', - } - ); - }, - } - ); - } - }, - [nameInputRef, projectId, feature.id, editFeature, addToast, queryClient] - ); - - return ( - <> -
    - -
    - - {Boolean(feature.scenarioUsageCount) && ( - - Currently in use in - - {feature.scenarioUsageCount} - - scenarios. - - )} -
    -
    -
    - {feature.tag && ( - - {feature.tag} - - )} -
    -
    - - - - - - - - - -
    - - ); -}; - -export default FeatureItemList; diff --git a/app/layout/project/sidebar/project/inventory-panel/features/modals/edit-bulk/index.tsx b/app/layout/project/sidebar/project/inventory-panel/features/modals/edit-bulk/index.tsx index 3b9b66ca29..33b30b7824 100644 --- a/app/layout/project/sidebar/project/inventory-panel/features/modals/edit-bulk/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/features/modals/edit-bulk/index.tsx @@ -216,7 +216,7 @@ const EditBulkModal = ({ {values.tag && tagIsDone && (
    -

    {values.tag}

    +

    {values.tag}

    + + + {TableComponent && } + {/* TODO: Upload modal won't be optional; currently checking their existence only for development purposes */} + {UploadModalComponent && ( + + )} + {/* Not all panels have a FooterComponent */} + {FooterComponent && } + + ); +}; + +export default InventoryPanel; diff --git a/app/layout/project/sidebar/project/inventory-panel/protected-areas/footer/index.tsx b/app/layout/project/sidebar/project/inventory-panel/protected-areas/footer/index.tsx new file mode 100644 index 0000000000..081684cc67 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/protected-areas/footer/index.tsx @@ -0,0 +1,24 @@ +const ProtectedAreasFooter = (): JSX.Element => { + return ( +
    +

    + UNEP-WCMC and IUCN (2022), Protected Planet: The World Database on Protected Areas (WDPA) + [On-line], [05/2022], Cambridge, UK: UNEP-WCMC and IUCN. +

    + +

    + Available at:{' '} + + www.protectedplanet.net + +

    +
    + ); +}; + +export default ProtectedAreasFooter; diff --git a/app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx b/app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx index 08698c3301..c564d99d16 100644 --- a/app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/protected-areas/index.tsx @@ -1,7 +1,9 @@ -import Section from 'layout/section'; - -const InventoryPanelProtectedAreas = (): JSX.Element => { - return
    InventoryPanelProtectedAreas
    ; +const InventoryPanelProtectedAreas = ({ + noData: noDataMessage, +}: { + noData: string; +}): JSX.Element => { + return
    {noDataMessage}
    ; }; export default InventoryPanelProtectedAreas; diff --git a/app/layout/project/sidebar/project/inventory-panel/types.ts b/app/layout/project/sidebar/project/inventory-panel/types.ts new file mode 100644 index 0000000000..644c32bccd --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/types.ts @@ -0,0 +1,17 @@ +export type InventoryPanel = { + title: string; + search: string; + noData: string; + InfoComponent?: () => JSX.Element; + // TODO: Remove optional when we have upload modals for all tabs + UploadModalComponent?: ({ + isOpen, + onDismiss, + }: { + isOpen?: boolean; + onDismiss: () => void; + }) => JSX.Element; + // TODO: Remove optional when we have table components for all tabs + TableComponent?: (props) => JSX.Element; + FooterComponent?: () => JSX.Element; +}; diff --git a/app/layout/sidebar/constants.ts b/app/layout/sidebar/constants.ts deleted file mode 100644 index 50ead6fd76..0000000000 --- a/app/layout/sidebar/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SidebarTreeCategories } from './types'; - -export const SIDEBAR_TREE = { - user: [], - inventory: ['protected-areas', 'cost-surface', 'features'], - gridSetup: ['protected-areas', 'cost-surface', 'planning-unit-status'], - solutions: ['solutions', 'target-achievement'], - advancedSettings: ['advanced-settings', 'blm-calibration'], -} satisfies { [key in SidebarTreeCategories]: string[] }; - -export const MENU_COMMON_CLASSES = 'flex flex-col items-center space-y-2'; -export const MENU_ITEM_COMMON_CLASSES = - 'flex group rounded-xl cursor-pointer bg-transparent transition-colors first:mt-2'; - -export const MENU_ITEM_ACTIVE_CLASSES = - 'group/active bg-primary-400 border-primary-400 hover:border-primary-400'; - -export const ICONS_COMMON_CLASSES = - 'h-5 w-5 text-gray-500 group-hover:text-white group-hover/active:!text-gray-500'; - -export const MENU_ITEM_BUTTON_COMMON_CLASSES = 'flex p-[10px]'; diff --git a/app/layout/sidebar/hooks.tsx b/app/layout/sidebar/hooks.tsx deleted file mode 100644 index 4e9d3cdb98..0000000000 --- a/app/layout/sidebar/hooks.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useRouter } from 'next/router'; - -import COST_SURFACE_SVG from 'svgs/sidebar/cost-surface.svg?sprite'; -import FEATURES_SVG from 'svgs/sidebar/features.svg?sprite'; -import PLANNING_UNIT_STATUS_SVG from 'svgs/sidebar/planning-unit-status.svg?sprite'; -import PROTECTED_AREA_SVG from 'svgs/sidebar/protected-area.svg?sprite'; - -import type { SubMenuItem } from './submenu'; - -const SCENARIO_ROUTE = '/projects/[pid]/scenarios/[sid]/edit'; - -export const useInventoryItems = (): SubMenuItem[] => { - const { query, route } = useRouter(); - const { pid, tab } = query as { pid: string; tab: string }; - const isProjectRoute = route === '/projects/[pid]'; - - return [ - { - name: 'Protected areas', - route: `/projects/${pid}?tab=protected-areas`, - icon: PROTECTED_AREA_SVG, - selected: isProjectRoute && tab === 'protected-areas', - }, - { - name: 'Cost surface', - route: `/projects/${pid}?tab=cost-surface`, - icon: COST_SURFACE_SVG, - selected: isProjectRoute && tab === 'cost-surface', - }, - - { - name: 'Features', - route: `/projects/${pid}?tab=features`, - icon: FEATURES_SVG, - selected: isProjectRoute && tab === 'features', - }, - ]; -}; - -export const useGridSetupItems = (): SubMenuItem[] => { - const { query, route } = useRouter(); - const { pid, sid, tab } = query as { pid: string; sid: string; tab: string }; - const isScenarioRoute = route === SCENARIO_ROUTE; - - return [ - { - name: 'Protected areas', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=protected-areas`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'protected-areas', - }, - { - name: 'Cost Surface', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=cost-surface`, - icon: COST_SURFACE_SVG, - selected: isScenarioRoute && tab === 'cost-surface', - }, - { - name: 'Planning unit status', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=planning-unit-status`, - icon: PLANNING_UNIT_STATUS_SVG, - selected: isScenarioRoute && tab === 'planning-unit-status', - }, - { - name: 'Features', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=features`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'features', - }, - ]; -}; - -export const useSolutionItems = (): SubMenuItem[] => { - const { query, route } = useRouter(); - const { pid, sid, tab } = query as { pid: string; sid: string; tab: string }; - const isScenarioRoute = route === SCENARIO_ROUTE; - - return [ - { - name: 'Overview', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=solutions`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'solutions', - }, - { - name: 'Target achievement', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=target-achievement`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'target-achievement', - }, - ]; -}; - -export const useAdvancedSettingsItems = (): SubMenuItem[] => { - const { query, route } = useRouter(); - const { pid, sid, tab } = query as { pid: string; sid: string; tab: string }; - const isScenarioRoute = route === SCENARIO_ROUTE; - - return [ - { - name: 'Overview', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=advanced-settings`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'advanced-settings', - }, - { - name: 'BLM calibration', - route: `/projects/${pid}/scenarios/${sid}/edit?tab=blm-calibration`, - icon: PROTECTED_AREA_SVG, - selected: isScenarioRoute && tab === 'blm-calibration', - }, - ]; -}; diff --git a/app/layout/sidebar/index.tsx b/app/layout/sidebar/index.tsx deleted file mode 100644 index a83291f674..0000000000 --- a/app/layout/sidebar/index.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { PropsWithChildren, useCallback, useState } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; - -import { TippyProps } from '@tippyjs/react/headless'; - -import Icon from 'components/icon'; -import { Popover, PopoverContent, PopoverTrigger } from 'components/popover'; -import Tooltip from 'components/tooltip'; -import { cn } from 'utils/cn'; - -import ADVANCED_SETTINGS_SVG from 'svgs/sidebar/advanced-settings.svg?sprite'; -import GRID_SETUP_SVG from 'svgs/sidebar/grid-setup.svg?sprite'; -import INVENTORY_SVG from 'svgs/sidebar/inventory.svg?sprite'; -import WHITE_LOGO_SVG from 'svgs/sidebar/logo-white.svg'; -import MENU_SVG from 'svgs/sidebar/menu.svg?sprite'; -import RUN_SCENARIO_SVG from 'svgs/sidebar/run-scenario.svg?sprite'; -import SCENARIO_LIST_SVG from 'svgs/sidebar/scenario-list.svg?sprite'; -import SOLUTIONS_SVG from 'svgs/sidebar/solutions.svg?sprite'; - -import { - MENU_COMMON_CLASSES, - MENU_ITEM_COMMON_CLASSES, - MENU_ITEM_ACTIVE_CLASSES, - MENU_ITEM_BUTTON_COMMON_CLASSES, - ICONS_COMMON_CLASSES, - SIDEBAR_TREE, -} from './constants'; -import { - useInventoryItems, - useGridSetupItems, - useSolutionItems, - useAdvancedSettingsItems, -} from './hooks'; -import SubMenu from './submenu'; -import type { SidebarTreeCategories } from './types'; -import UserMenu from './user-menu'; - -export const MenuTooltip = ({ children }: PropsWithChildren): JSX.Element => { - return ( -
    - {children} -
    - ); -}; - -export const TOOLTIP_OFFSET: TippyProps['offset'] = [0, 10]; - -export const Sidebar = (): JSX.Element => { - const { query, route } = useRouter(); - const { pid, sid, tab } = query as { pid: string; sid: string; tab: string }; - - const isProjectRoute = route === '/projects/[pid]'; - const isScenarioRoute = route === '/projects/[pid]/scenarios/[sid]/edit'; - - const [submenuState, setSubmenuState] = useState<{ [key in SidebarTreeCategories]: boolean }>({ - user: false, - inventory: isProjectRoute && SIDEBAR_TREE.inventory.includes(tab), - gridSetup: isScenarioRoute && SIDEBAR_TREE.gridSetup.includes(tab), - solutions: isScenarioRoute && SIDEBAR_TREE.solutions.includes(tab), - advancedSettings: isScenarioRoute && SIDEBAR_TREE.advancedSettings.includes(tab), - }); - - const inventoryItems = useInventoryItems(); - const gridSetupItems = useGridSetupItems(); - const solutionsItems = useSolutionItems(); - const advancedSettingsItems = useAdvancedSettingsItems(); - - const toggleSubmenu = useCallback((submenuKey: SidebarTreeCategories) => { - if (submenuKey === 'user') { - return setSubmenuState((prevState) => ({ - ...prevState, - [submenuKey]: !prevState[submenuKey], - })); - } - - return setSubmenuState((prevState) => { - return Object.keys(prevState).reduce( - (acc, key) => ({ - ...acc, - [key]: key === submenuKey ? !prevState[key] : false, - }), - prevState - ); - }); - }, []); - - const handleRunScenario = useCallback(() => { - // todo: define run scenario button behaviour - console.log('handleRunScenario', sid); - }, [sid]); - - return ( - - ); -}; - -export default Sidebar; diff --git a/app/layout/sidebar/submenu/index.tsx b/app/layout/sidebar/submenu/index.tsx deleted file mode 100644 index 052cbea826..0000000000 --- a/app/layout/sidebar/submenu/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import Link from 'next/link'; - -import { cn } from 'utils/cn'; - -import Icon, { IconProps } from 'components/icon'; -import Tooltip from 'components/tooltip'; - -import { MenuTooltip, TOOLTIP_OFFSET } from '../'; - -export interface SubMenuItem { - name: string; - icon: IconProps['icon']; - route: string; - selected: boolean; -} - -export const SubMenu = ({ items }: { items: SubMenuItem[] }): JSX.Element => { - return ( -
      - {items.map((item) => ( - {item.name}} - > -
    • - - - -
    • -
      - ))} -
    - ); -}; - -export default SubMenu; diff --git a/app/layout/sidebar/types.ts b/app/layout/sidebar/types.ts deleted file mode 100644 index c1877a3ee4..0000000000 --- a/app/layout/sidebar/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type SidebarTreeCategories = - | 'user' - | 'inventory' - | 'gridSetup' - | 'solutions' - | 'advancedSettings'; diff --git a/app/layout/sidebar/user-menu/constants.ts b/app/layout/sidebar/user-menu/constants.ts deleted file mode 100644 index fdd86b29fc..0000000000 --- a/app/layout/sidebar/user-menu/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const ITEM_COMMON_CLASSES = 'flex items-center justify-between rounded-3xl bg-gray-50 p-4'; -export const ITEM_TITLE_COMMON_CLASSES = 'flex items-center space-x-2 font-medium text-black'; -export const ITEM_DESCRIPTION_COMMON_CLASSES = 'text-gray-400 leading-normal'; diff --git a/app/layout/sidebar/user-menu/index.tsx b/app/layout/sidebar/user-menu/index.tsx deleted file mode 100644 index 5e3be10301..0000000000 --- a/app/layout/sidebar/user-menu/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback } from 'react'; - -import { useQuery } from 'react-query'; - -import Link from 'next/link'; - -import axios from 'axios'; -import { signOut, useSession } from 'next-auth/react'; -import { usePlausible } from 'next-plausible'; - -import { useHelp } from 'hooks/help'; -import { useMe } from 'hooks/me'; -import { COLOR_ME } from 'hooks/project-users'; - -import Avatar from 'components/avatar'; -import Button from 'components/button'; -import { Switch } from 'components/forms/switch'; -import Icon from 'components/icon'; -import { cn } from 'utils/cn'; - -import HELP_GUIDE_SVG from 'svgs/sidebar/help-guide.svg?sprite'; -import EDIT_PROFILE_SVG from 'svgs/sidebar/pencil.svg?sprite'; -import DOCUMENTATION_SVG from 'svgs/ui/documentation.svg?sprite'; -import SIGN_OUT_SVG from 'svgs/ui/sign-out.svg?sprite'; - -import { - ITEM_COMMON_CLASSES, - ITEM_TITLE_COMMON_CLASSES, - ITEM_DESCRIPTION_COMMON_CLASSES, -} from './constants'; - -export const UserMenu = (): JSX.Element => { - const { data: session } = useSession(); - const { user } = useMe(); - const { active, onActive } = useHelp(); - const plausible = usePlausible(); - - const { data: totalProjects } = useQuery(['user-total-projects', user.id], { - queryFn: () => - axios - .get(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/projects`, { - headers: { - Authorization: `Bearer ${session.accessToken}`, - }, - }) - .then((response) => response.data), - enabled: Boolean(session), - select: ({ meta }) => meta.totalItems, - }); - - const handleSignOut = useCallback(async () => { - await signOut(); - }, []); - - const onToggleHelpGuide = useCallback(() => { - onActive(!active); - if (active) { - plausible('Activate help guide', { - props: { - userId: `${user.id}`, - userEmail: `${user.email}`, - }, - }); - } - }, [active, onActive, plausible, user.id, user.email]); - - return ( -
    -
    -
    - - {!user.avatar && user.displayName.slice(0, 2).toUpperCase()} - -
    -
    -
    -

    {user.displayName}

    -

    {user.email}

    -
    -
    - - -
    -
    -
    -
    -
      -
    • - -
      - -
      -
      -

      - Projects dashboard - - {totalProjects} - -

      - - Get a high-level summary of your projects and progress. - -
      - -
    • - {/*
    • - -
      - -
      -
      -

      Manage team

      - - Lorem ipsum dolor sit amet augue fringilla consequat - -
      - -
    • */} -
    • -
      -
      - -
      - Help Guide -
      - -
    • -
    • - -
      - -
      -

      - Community projects -

      - -
    • -
    -
    -
    - ); -}; - -export default UserMenu; diff --git a/app/pages/projects/[pid]/index.tsx b/app/pages/projects/[pid]/index.tsx index b4a76068a9..a8389d9596 100644 --- a/app/pages/projects/[pid]/index.tsx +++ b/app/pages/projects/[pid]/index.tsx @@ -8,9 +8,7 @@ import ProjectLayout from 'layout/project'; import Breadcrumbs from 'layout/project/navigation/breadcrumbs'; import Sidebar from 'layout/project/sidebar'; import InventoryProjectHeader from 'layout/project/sidebar/project/header'; -import InventoryPanelCostSurface from 'layout/project/sidebar/project/inventory-panel/cost-surface'; -import InventoryPanelFeatures from 'layout/project/sidebar/project/inventory-panel/features'; -import InventoryPanelProtectedAreas from 'layout/project/sidebar/project/inventory-panel/protected-areas'; +import InventoryPanel from 'layout/project/sidebar/project/inventory-panel'; import ScenariosList from 'layout/project/sidebar/project/scenarios-list'; import ProjectMap from 'layout/projects/show/map'; import ProjectStatus from 'layout/projects/show/status'; @@ -30,12 +28,8 @@ const ShowProjectsPage = (): JSX.Element => { - - - {tab === 'features' && } - {tab === 'protected-areas' && } - {tab === 'cost-surface' && } + {tab && } {!tab && } diff --git a/app/svgs/ui/edit.svg b/app/svgs/ui/edit.svg new file mode 100644 index 0000000000..2ce49b14ad --- /dev/null +++ b/app/svgs/ui/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/svgs/ui/tag.svg b/app/svgs/ui/tag.svg new file mode 100644 index 0000000000..a8f174ebd0 --- /dev/null +++ b/app/svgs/ui/tag.svg @@ -0,0 +1,3 @@ + + +