From 0de376986cf75e59390610e499ed107a4c534cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 4 Dec 2023 17:13:56 +0100 Subject: [PATCH] WIP --- .../features/selected-item/component.tsx | 1 - app/hooks/features/index.ts | 75 ++- app/hooks/features/types.ts | 2 + .../project/sidebar/project/header/index.tsx | 2 +- .../features/add/add-modal/component.tsx | 5 +- .../scenario/grid-setup/features/index.tsx | 46 +- .../features/target-spf/all-targets/index.tsx | 114 ++++ .../target-spf/bulk-action-menu/index.tsx | 66 +++ .../bulk-action-menu/modals/delete/index.tsx | 131 +++++ .../grid-setup/features/target-spf/index.tsx | 547 ++++++++++++++++++ .../targets-spf-table/actions-menu/index.tsx | 125 ++++ .../targets-spf-table/header-item/index.tsx | 67 +++ .../target-spf/targets-spf-table/index.tsx | 123 ++++ .../row-item/details/index.tsx | 66 +++ .../targets-spf-table/row-item/index.tsx | 179 ++++++ .../target-spf/targets-spf-table/types.ts | 36 ++ app/layout/scenarios/sidebar/component.tsx | 2 +- 17 files changed, 1511 insertions(+), 76 deletions(-) create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/all-targets/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/header-item/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/details/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/index.tsx create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/types.ts diff --git a/app/components/features/selected-item/component.tsx b/app/components/features/selected-item/component.tsx index 3c8558eeef..17717f1e24 100644 --- a/app/components/features/selected-item/component.tsx +++ b/app/components/features/selected-item/component.tsx @@ -181,7 +181,6 @@ export const Item: React.FC = ({ ) : ( )} - {/* */} diff --git a/app/hooks/features/index.ts b/app/hooks/features/index.ts index 0352a542ab..537f2f24ae 100644 --- a/app/hooks/features/index.ts +++ b/app/hooks/features/index.ts @@ -172,7 +172,7 @@ export function useSelectedFeatures( queryOptions = {} ) { const { data: session } = useSession(); - const { search } = filters; + const { search, sort, tag } = filters; const queryClient = useQueryClient(); @@ -271,6 +271,7 @@ export function useSelectedFeatures( id: featureId, name: alias || featureClassName, type: tag, + // type: Math.random() < 0.5 ? 'test' : 'andres', description, amountRange: { min: amountMin, @@ -291,12 +292,12 @@ export function useSelectedFeatures( }; }); - // Filter if (search) { const fuse = new Fuse(parsedData, { keys: ['name'], threshold: 0.25, }); + parsedData = fuse.search(search).map((f) => { return f.item; }); @@ -314,7 +315,7 @@ export function useTargetedFeatures( queryOptions = {} ) { const { data: session } = useSession(); - const { search } = filters; + const { search, sort } = filters; const fetchFeatures = () => SCENARIOS.request({ @@ -325,10 +326,16 @@ export function useTargetedFeatures( }, params: { disablePagination: true, + ...(search && { + q: search, + }), + ...(sort && { + sort, + }), }, }).then(({ data }) => data); - return useQuery(['targeted-features', sid], fetchFeatures, { + return useQuery(['targeted-features', sid, filters], fetchFeatures, { ...queryOptions, retry: false, enabled: !!sid, @@ -405,6 +412,11 @@ export function useTargetedFeatures( name: alias || featureClassName, type: tag, description, + // todo: missing scenarioUsageCount from API + scenarios: d.scenarioUsageCount, + // todo: missing tag from API + tag: d.tag, + isCustom: d.metadata?.isCustom, // SPLIT splitOptions, @@ -429,7 +441,16 @@ export function useTargetedFeatures( } // Sort - parsedData = orderBy(parsedData, ['name'], ['desc']); + if (sort) { + parsedData.sort((a, b) => { + if (sort.startsWith('-')) { + const _sort = sort.substring(1); + return b[_sort].localeCompare(a[_sort]); + } + + return a[sort].localeCompare(b[sort]); + }); + } parsedData = flatten( parsedData.map((s) => { @@ -439,25 +460,27 @@ export function useTargetedFeatures( // Generate splitted features to target if (isSplitted) { - return splitFeaturesSelected - .sort((a, b) => a.name.localeCompare(b.name)) - .map((sf) => { - const { id: sfId, name: sfName, marxanSettings: sfMarxanSettings } = sf; - - return { - ...sf, - id: `${id}-${sfId}`, - parentId: id, - name: `${name} / ${sfName}`, - splitted: true, - splitSelected, - splitFeaturesSelected, - ...(!!sfMarxanSettings && { - target: sfMarxanSettings.prop * 100, - fpf: sfMarxanSettings.fpf, - }), - }; - }); + return ( + splitFeaturesSelected + // .sort((a, b) => a.name.localeCompare(b.name)) + .map((sf) => { + const { id: sfId, name: sfName, marxanSettings: sfMarxanSettings } = sf; + + return { + ...sf, + id: `${id}-${sfId}`, + parentId: id, + name: `${name} / ${sfName}`, + splitted: true, + splitSelected, + splitFeaturesSelected, + ...(!!sfMarxanSettings && { + target: sfMarxanSettings.prop * 100, + fpf: sfMarxanSettings.fpf, + }), + }; + }) + ); } // if (isIntersected) { @@ -518,11 +541,9 @@ export function useSaveSelectedFeatures({ }; return useMutation(saveFeature, { - onSuccess: (data, variables, context) => { + onSuccess: (data, variables) => { const { id } = variables; queryClient.setQueryData(['selected-features', id], { data: data?.data }); - - console.info('Succces', data, variables, context); }, onError: (error, variables, context) => { // An error happened! diff --git a/app/hooks/features/types.ts b/app/hooks/features/types.ts index 4f47a3c8af..5334fa4f66 100644 --- a/app/hooks/features/types.ts +++ b/app/hooks/features/types.ts @@ -2,6 +2,8 @@ import { AxiosRequestConfig } from 'axios'; export interface UseFeaturesFiltersProps { search?: string; + sort?: string; + tag?: string; } export interface UseSaveSelectedFeaturesProps { diff --git a/app/layout/project/sidebar/project/header/index.tsx b/app/layout/project/sidebar/project/header/index.tsx index 15ab01b211..074c1f2920 100644 --- a/app/layout/project/sidebar/project/header/index.tsx +++ b/app/layout/project/sidebar/project/header/index.tsx @@ -45,7 +45,7 @@ const InventoryProjectHeader = (): JSX.Element => { ); return ( -
+
<div className="mt-4 flex items-center space-x-5"> diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/component.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/component.tsx index 541c22f825..1d6239ecce 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/component.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/add/add-modal/component.tsx @@ -112,9 +112,10 @@ export const ScenariosFeaturesAdd = (): JSX.Element => { }, }, { - onSuccess: () => { + onSuccess: async () => { setOpen(false); - queryClient.invalidateQueries(['selected-features', sid]); + await queryClient.invalidateQueries(['selected-features', sid]); + await queryClient.invalidateQueries(['targeted-features', sid]); }, onSettled: () => { setSubmitting(false); diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/index.tsx index 0d44961b1c..239b30a754 100644 --- a/app/layout/project/sidebar/scenario/grid-setup/features/index.tsx +++ b/app/layout/project/sidebar/scenario/grid-setup/features/index.tsx @@ -1,49 +1,7 @@ -import { useRouter } from 'next/router'; - -import { TABS } from 'layout/project/navigation/constants'; -import StepManager from 'layout/step-manager'; - -import GridSetupFeaturesAdd from './add'; -import GridSetupFeaturesTargets from './targets'; +import GridSetupFeaturesTargets from './target-spf'; export const GridSetupFeatures = (): JSX.Element => { - const { replace, query } = useRouter(); - const { pid, sid, tab } = query as { pid: string; sid: string; tab: string }; - - return ( - <StepManager defaultStep={tab === TABS['scenario-features-targets-spf'] ? 1 : 0}> - {({ currentStep, onChangeStep }) => ( - <> - {currentStep === 0 && ( - <GridSetupFeaturesAdd - onContinue={() => { - onChangeStep(1); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - replace( - `/projects/${pid}/scenarios/${sid}/edit?tab=${TABS['scenario-features-targets-spf']}`, - null, - { shallow: true } - ); - }} - /> - )} - {currentStep === 1 && ( - <GridSetupFeaturesTargets - onGoBack={() => { - onChangeStep(0); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - replace( - `/projects/${pid}/scenarios/${sid}/edit?tab=${TABS['scenario-features']}`, - null, - { shallow: true } - ); - }} - /> - )} - </> - )} - </StepManager> - ); + return <GridSetupFeaturesTargets />; }; export default GridSetupFeatures; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/all-targets/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/all-targets/index.tsx new file mode 100644 index 0000000000..67cbb3811b --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/all-targets/index.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; + +import Input from 'components/forms/input'; + +const DEFAULT_INPUT_VALUES = { + target: 50, + spf: 1, +}; + +const INPUT_CLASSES = + 'w-[55px] rounded-md border-solid border-gray-600 bg-gray-900 bg-opacity-100 px-0 py-1 text-center leading-tight'; + +const AllTargetsSelector = ({ + onChangeAllTargets, + onChangeAllSPF, +}: { + onChangeAllTargets: (target: number) => void; + onChangeAllSPF: (spf: number) => void; +}): JSX.Element => { + const [values, setValues] = useState(DEFAULT_INPUT_VALUES); + + return ( + <div className="flex justify-between rounded-lg bg-gray-700 px-[10px] py-[5px] text-sm"> + <span className="flex max-w-[115px] text-xs text-white"> + Set target and SPF in all features: + </span> + <div className="flex space-x-2"> + <div className="flex items-center space-x-2"> + <span>Target</span> + <Input + className={INPUT_CLASSES} + theme="dark" + mode="dashed" + type="number" + min={0} + max={100} + defaultValue={values.target} + value={values.target} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + target: Number(inputValue), + })); + }} + onKeyDownCapture={(event) => { + if (event.key === 'Enter') { + onChangeAllTargets(Number(values.target)); + } + }} + onBlur={() => { + // If user leaves the input empty, we'll revert to the original targetValue + console.log(values); + if (!values.target) { + return setValues((prevValues) => ({ + ...prevValues, + target: DEFAULT_INPUT_VALUES.target, + })); + } + + setValues((prevValues) => ({ + ...prevValues, + target: values.target, + })); + + onChangeAllTargets(Number(values.target)); + }} + /> + <span className="text-xs">%</span> + </div> + <div className="flex items-center space-x-2"> + <span>SPF</span> + <Input + className={INPUT_CLASSES} + theme="dark" + mode="dashed" + type="number" + defaultValue={values.spf} + // value={inputFPFValue} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + spf: Number(inputValue), + })); + }} + onKeyDownCapture={(event) => { + if (event.key === 'Enter') { + onChangeAllSPF(Number(values.spf)); + } + }} + onBlur={() => { + if (!values.spf) { + return setValues((prevValues) => ({ + ...prevValues, + target: DEFAULT_INPUT_VALUES.spf, + })); + } + + setValues((prevValues) => ({ + ...prevValues, + spf: values.spf, + })); + + onChangeAllSPF(Number(values.spf)); + }} + /> + </div> + </div> + </div> + ); +}; + +export default AllTargetsSelector; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx new file mode 100644 index 0000000000..e7fa38c725 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; + +import Button from 'components/button'; +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import { Feature } from 'types/api/feature'; + +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; + +import DeleteModal from './modals/delete'; + +const BUTTON_CLASSES = + 'col-span-1 flex items-center space-x-2 rounded-lg bg-gray-800 px-4 text-xs text-gray-100'; +const ICON_CLASSES = 'h-5 w-5 transition-colors text-gray-100 group-hover:text-gray-100'; + +const SplitFeaturesBulkActionMenu = ({ + features, + selectedFeatureIds, +}: { + features: Feature[]; + selectedFeatureIds: Feature['id'][]; +}): JSX.Element => { + const [modalState, setModalState] = useState<{ edit: boolean; delete: boolean }>({ + edit: false, + delete: false, + }); + + const handleModal = useCallback((modalKey: keyof typeof modalState, isVisible: boolean) => { + setModalState((prevState) => ({ ...prevState, [modalKey]: isVisible })); + }, []); + + return ( + <> + <div className="grid w-full grid-cols-2 items-center space-x-2 rounded-xl bg-gray-600 p-1"> + <span className="col-span-1 flex items-center justify-center space-x-2"> + <span className="block w-[20px] rounded-[4px] bg-blue-500/25 px-1 text-center text-xs font-semibold text-blue-500"> + {selectedFeatureIds.length} + </span> + <span className="text-xs text-gray-100">Selected</span> + </span> + + <Button + theme="secondary" + size="base" + className={BUTTON_CLASSES} + onClick={() => handleModal('delete', true)} + > + <Icon icon={DELETE_SVG} className={ICON_CLASSES} /> + <span>Delete</span> + </Button> + </div> + + <Modal + id="delete-split-features-modal" + open={modalState.delete} + size="narrow" + dismissable + onDismiss={() => handleModal('delete', false)} + > + <DeleteModal features={features} selectedFeaturesIds={selectedFeatureIds} /> + </Modal> + </> + ); +}; + +export default SplitFeaturesBulkActionMenu; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx new file mode 100644 index 0000000000..8e247458d5 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/modals/delete/index.tsx @@ -0,0 +1,131 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useSession } from 'next-auth/react'; + +import { useToasts } from 'hooks/toast'; + +import { Button } from 'components/button/component'; +import Icon from 'components/icon/component'; +import { ModalProps } from 'components/modal'; +import { bulkDeleteFeatureFromProject } from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/utils'; +import { Feature } from 'types/api/feature'; + +import ALERT_SVG from 'svgs/ui/new-layout/alert.svg?sprite'; + +const DeleteModal = ({ + features, + selectedFeaturesIds, + onDismiss, +}: { + features: Feature[]; + selectedFeaturesIds: Feature['id'][]; + onDismiss?: ModalProps['onDismiss']; +}): JSX.Element => { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const { query } = useRouter(); + const { pid, sid } = query as { pid: string; sid: string }; + const { addToast } = useToasts(); + + const selectedFeatures = useMemo( + () => features.filter(({ id }) => selectedFeaturesIds.includes(id)) ?? [], + [features, selectedFeaturesIds] + ); + + console.log({ selectedFeatures }); + + const featureNames = selectedFeatures.map(({ name }) => name); + // ? the user will be able to delete the features only if they are not being used by any scenario. + const haveScenarioAssociated = selectedFeatures.some(({ scenarioUsageCount }) => + Boolean(scenarioUsageCount) + ); + + const handleBulkDelete = useCallback(() => { + const deletableFeatureIds = selectedFeatures.map(({ id }) => id); + + bulkDeleteFeatureFromProject(pid, deletableFeatureIds, session) + .then(async () => { + await queryClient.invalidateQueries(['selected-features', sid]); + await queryClient.invalidateQueries(['project-tags', pid]); + + onDismiss(); + + addToast( + 'delete-bulk-project-features', + <> + <h2 className="font-medium">Success</h2> + <p className="text-sm">The features were deleted successfully.</p> + </>, + { + level: 'success', + } + ); + }) + .catch(() => { + addToast( + 'delete-bulk-project-features', + <> + <h2 className="font-medium">Error!</h2> + <p className="text-sm">Something went wrong deleting the features</p> + </>, + { + level: 'error', + } + ); + }); + }, [selectedFeatures, addToast, onDismiss, pid, queryClient, session]); + + return ( + <div className="flex flex-col space-y-5 px-8 py-1"> + <h2 className="font-heading font-bold text-black">{`Delete feature${ + selectedFeaturesIds.length > 1 ? 's' : '' + }`}</h2> + <p className="font-heading text-sm font-medium text-black"> + {selectedFeaturesIds.length > 1 ? ( + <div className="space-y-2"> + <span> + Are you sure you want to delete the following features? <br /> + This action cannot be undone. + </span> + <ul> + {featureNames.map((name) => ( + <li key={name}>{name}</li> + ))} + </ul> + </div> + ) : ( + <span> + Are you sure you want to delete "{featureNames[0]}" feature? <br /> + This action cannot be undone. + </span> + )} + </p> + <div className="flex items-center space-x-1.5 rounded border-l-[5px] border-red-700 bg-red-100/50 px-1.5 py-4"> + <Icon className="h-10 w-10 text-red-700" icon={ALERT_SVG} /> + <p className="font-sans text-xs font-medium text-black"> + A feature can be deleted ONLY if it's not being used by any scenario + </p> + </div> + <div className="flex w-full justify-between space-x-3 px-10 py-2"> + <Button theme="secondary" size="lg" className="w-full" onClick={onDismiss}> + Cancel + </Button> + <Button + theme="danger-alt" + size="lg" + className="w-full" + disabled={haveScenarioAssociated} + onClick={handleBulkDelete} + > + Delete + </Button> + </div> + </div> + ); +}; + +export default DeleteModal; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx new file mode 100644 index 0000000000..e71a393ee2 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx @@ -0,0 +1,547 @@ +import { ChangeEvent, ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; + +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { getScenarioEditSlice } from 'store/slices/scenarios/edit'; + +import Fuse from 'fuse.js'; +import { useDebouncedCallback } from 'use-debounce'; + +import { useSaveSelectedFeatures, useSelectedFeatures } from 'hooks/features'; + +import Button from 'components/button'; +import ConfirmationPrompt from 'components/confirmation-prompt'; +import Icon from 'components/icon'; +import InfoButton from 'components/info-button'; +import Search from 'components/search'; +import FeaturesInfo from 'layout/project/sidebar/project/inventory-panel/features/info'; +import AddFeaturesModal from 'layout/project/sidebar/scenario/grid-setup/features/add/add-modal'; +import TargetsSPFTable from 'layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table'; +import ActionsMenu from 'layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu'; +import Section from 'layout/section'; +import { Feature } from 'types/api/feature'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +import AllTargetsSelector from './all-targets'; +import FeaturesBulkActionMenu from './bulk-action-menu'; + +const TARGET_SPF_TABLE_COLUMNS = [ + { + name: 'name', + text: 'Name', + }, + { + name: 'type', + text: 'Type', + }, +] satisfies ComponentProps<typeof TargetsSPFTable>['columns']; + +const TargetAndSPFFeatures = (): JSX.Element => { + const queryClient = useQueryClient(); + const { query } = useRouter(); + const { sid } = query as { pid: string; sid: string }; + const [filters, setFilters] = useState({ + sort: TARGET_SPF_TABLE_COLUMNS[0].name, + search: null, + type: null, + }); + const [list, setList] = useState<any[]>([]); + const [featureValues, setFeatureValues] = useState< + Record< + string, + { + target: number; + spf: number; + } + > + >({}); + const [confirmationTarget, setConfirmationTarget] = useState<number>(null); + const [confirmationFPF, setConfirmationFPF] = useState<number>(null); + const [selectedFeatureIds, setSelectedFeatureIds] = useState<Feature['id'][]>([]); + const selectedFeaturesMutation = useSaveSelectedFeatures({}); + + const dispatch = useAppDispatch(); + + const scenarioSlice = getScenarioEditSlice(sid); + const { setSelectedFeatures, setSelectedContinuousFeatures, setLayerSettings } = + scenarioSlice.actions; + const { selectedFeatures, selectedContinuousFeatures } = useAppSelector( + (state) => state[`/scenarios/${sid}/edit`] + ); + + const selectedFeaturesQuery = useSelectedFeatures(sid, filters, { + keepPreviousData: true, + }); + + const handleSearch = useDebouncedCallback( + (value: Parameters<ComponentProps<typeof Search>['onChange']>[0]) => { + setFilters((prevFilters) => ({ + ...prevFilters, + search: value, + })); + }, + 500 + ); + + const handleSort = useCallback( + (_sortType: (typeof filters)['sort']) => { + const sort = filters.sort === _sortType ? `-${_sortType}` : _sortType; + setFilters((prevFilters) => ({ + ...prevFilters, + sort, + })); + }, + [filters.sort] + ); + + const handleTagFilter = useCallback((type: Feature['tag']) => { + setFilters((prevFilters) => ({ + ...prevFilters, + type, + })); + }, []); + + const toggleSeeOnMap = useCallback( + (id: Feature['id']) => { + const binaryFeatures = [...selectedFeatures]; + const continuousFeatures = [...selectedContinuousFeatures]; + + const isIncludedInBinary = binaryFeatures.includes(id); + const isIncludedInContinuous = continuousFeatures.includes(id); + + const feature = targetedFeatures.find(({ id: featureId }) => featureId === id); + const isContinuous = feature.amountRange.min !== null && feature.amountRange.max !== null; + + if (isContinuous) { + if (!isIncludedInContinuous) { + continuousFeatures.push(id); + } else { + const i = continuousFeatures.indexOf(id); + continuousFeatures.splice(i, 1); + } + + dispatch(setSelectedContinuousFeatures(continuousFeatures)); + } else { + if (!isIncludedInBinary) { + binaryFeatures.push(id); + } else { + const i = binaryFeatures.indexOf(id); + binaryFeatures.splice(i, 1); + } + dispatch(setSelectedFeatures(binaryFeatures)); + } + + const selectedFeature = targetedFeatures.find(({ id: featureId }) => featureId === id); + const { color } = selectedFeature || {}; + + // console.log({ selectedFeature }); + const _id = selectedFeature.splitSelected ? selectedFeature.featureId : id; + + console.log({ + id: _id, + settings: { + visibility: !(isIncludedInBinary || isIncludedInContinuous), + color, + ...(isContinuous && { + amountRange: feature.amountRange, + }), + }, + }); + + dispatch( + setLayerSettings({ + id: _id, + settings: { + visibility: !(isIncludedInBinary || isIncludedInContinuous), + color, + ...(isContinuous && { + amountRange: feature.amountRange, + }), + }, + }) + ); + }, + [ + dispatch, + setSelectedFeatures, + setLayerSettings, + selectedFeatures, + selectedContinuousFeatures, + setSelectedContinuousFeatures, + selectedFeaturesQuery.data, + ] + ); + + const handleChangeAllTargets = useCallback( + (target: Parameters<ComponentProps<typeof AllTargetsSelector>['onChangeAllTargets']>[0]) => { + setConfirmationTarget(target); + }, + [] + ); + + const handleChangeAllSPF = useCallback( + (spf: Parameters<ComponentProps<typeof AllTargetsSelector>['onChangeAllSPF']>[0]) => { + setConfirmationFPF(spf); + }, + [] + ); + + const targetedFeatures = useMemo(() => { + let parsedData = []; + selectedFeaturesQuery.data?.forEach((feature) => { + // ? renders the split features as well + if (feature.splitFeaturesSelected?.length > 0) { + const splitFeatures = feature.splitFeaturesSelected.map((splitFeature) => ({ + ...splitFeature, + parentId: feature.id, + name: `${feature.name} / ${splitFeature.name}`, + isVisibleOnMap: selectedFeatures.includes(feature.id), + isCustom: feature.metadata?.isCustom, + splitted: true, + marxanSettings: { + ...splitFeature.marxanSettings, + ...(featureValues[splitFeature.id]?.target && { + prop: featureValues[splitFeature.id].target, + }), + ...(featureValues[splitFeature.id]?.spf && { + fpf: featureValues[splitFeature.id].spf, + }), + }, + })); + + parsedData = [...parsedData, ...splitFeatures]; + } else { + parsedData = [ + ...parsedData, + { + ...feature, + isVisibleOnMap: selectedFeatures.includes(feature.id), + isCustom: feature.metadata?.isCustom, + marxanSettings: { + ...feature.marxanSettings, + ...(featureValues[feature.id]?.target && { + prop: featureValues[feature.id].target, + }), + ...(featureValues[feature.id]?.spf && { fpf: featureValues[feature.id].spf }), + }, + }, + ]; + } + }); + + if (filters.sort) { + parsedData.sort((a, b) => { + if (filters.sort.startsWith('-')) { + const _sort = filters.sort.substring(1); + return b[_sort]?.localeCompare(a[_sort]); + } + + return a[filters.sort]?.localeCompare(b[filters.sort]); + }); + } + + if (filters.type) { + const fuse = new Fuse(parsedData, { + keys: ['type'], + threshold: 0.25, + }); + + parsedData = fuse.search(filters.type).map((f) => { + return f.item; + }); + } + + return parsedData; + }, [selectedFeaturesQuery.data, selectedFeatures, filters, featureValues]); + + const onApplyAllTargets = useCallback(() => { + setFeatureValues((prevValues) => { + const ids = targetedFeatures.map(({ id }) => id); + + return ids.reduce( + (acc, featureId) => ({ + ...acc, + [featureId]: { + ...prevValues[featureId], + target: confirmationTarget, + }, + }), + {} + ); + }); + setConfirmationTarget(null); + }, [confirmationTarget, targetedFeatures]); + + const onApplyAllSPF = useCallback(() => { + setFeatureValues((prevValues) => { + const ids = targetedFeatures.map(({ id }) => id); + + return ids.reduce( + (acc, featureId) => ({ + ...acc, + [featureId]: { + ...prevValues[featureId], + spf: confirmationFPF, + }, + }), + {} + ); + }); + setConfirmationFPF(null); + }, [confirmationFPF, targetedFeatures]); + + const handleSelectAllFeatures = useCallback( + (evt: ChangeEvent<HTMLInputElement>) => { + if (evt.target.checked) { + setSelectedFeatureIds(targetedFeatures.map(({ id }) => id)); + } else { + setSelectedFeatureIds([]); + } + }, + [targetedFeatures] + ); + + const handleSelectFeature = useCallback((evt: ChangeEvent<HTMLInputElement>) => { + if (evt.target.checked) { + setSelectedFeatureIds((prevFeatureIds) => [...prevFeatureIds, evt.target.value]); + } else { + setSelectedFeatureIds((prevFeatureIds) => + prevFeatureIds.filter((featureId) => featureId !== evt.target.value) + ); + } + }, []); + + const onSubmit = useCallback(() => { + // setSubmitting(true); + const features = list; + const featureIds = features.map(({ id }) => id); + + const data = { + status: 'created', + features: selectedFeaturesQuery.data + .filter((sf) => featureIds.includes(sf.featureId)) + .map((sf) => { + const { featureId, kind, geoprocessingOperations } = sf; + + if (kind === 'withGeoprocessing') { + return { + featureId, + kind, + geoprocessingOperations: geoprocessingOperations.map((go) => { + const { splits } = go; + + return { + ...go, + splits: splits + .filter((s) => { + return features.find((f) => { + // console.log({ f, s, featureId }); + return f.parentId === featureId && f.value === s.value; + }); + }) + .map((s) => { + const { + marxanSettings: { prop, fpf }, + } = features.find((f) => { + return f.parentId === featureId && f.value === s.value; + }); + + console.log({ features }); + + return { + ...s, + marxanSettings: { + prop: prop / 100, + fpf, + }, + }; + }), + }; + }), + }; + } + + const { target, spf = 1 } = featureValues[featureId] || {}; + return { + featureId, + kind, + marxanSettings: { + prop: target / 100 || 0.5, + fpf: spf, + }, + }; + }), + }; + + console.log({ data }); + + selectedFeaturesMutation.mutate( + { + id: sid, + data, + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(['selected-features', sid]); + }, + onSettled: () => { + // setSubmitting(false); + }, + } + ); + }, [ + sid, + queryClient, + targetedFeatures, + selectedFeaturesMutation, + featureValues, + selectedFeaturesQuery.data, + ]); + + const handleRowValues = useCallback((id, values) => { + setFeatureValues((prevValues) => ({ + ...prevValues, + [id]: { + ...prevValues[id], + ...values, + }, + })); + }, []); + + const handleRowDeletion = useCallback((id) => { + setList((prevList) => prevList.filter(({ id: featureId }) => featureId !== id)); + }, []); + + const displayBulkActions = selectedFeatureIds.length > 0; + + console.log({ list, featureValues, data: selectedFeaturesQuery.data }); + + useEffect(() => { + setList(targetedFeatures); + }, [targetedFeatures]); + + return ( + <> + <Section className="relative flex flex-col space-y-2 overflow-hidden"> + <header className="flex items-center justify-between"> + <div className="space-y-1"> + <span className="text-xs font-semibold text-blue-500">Inventory Panel</span> + <h3 className="flex items-center space-x-2"> + <span className="text-lg font-medium">Features</span> + <InfoButton theme="primary" className="bg-gray-400"> + <FeaturesInfo /> + </InfoButton> + </h3> + </div> + <AddFeaturesModal /> + </header> + <Search + id="target-spf-search" + size="sm" + placeholder="Search features" + aria-label="Search features" + onChange={handleSearch} + theme="dark" + /> + {filters.type && ( + <div className="flex items-center space-x-2 text-xs"> + <span className=" space-x-3"> + <span>Filtering by: </span> + <button + type="button" + className="inline-block rounded-2xl bg-yellow-500/10 px-3 py-0.5 text-yellow-500 transition-colors hover:bg-yellow-500 hover:text-gray-900" + onClick={() => handleTagFilter(null)} + > + {filters.type} + </button> + </span> + <button + type="button" + className="group inline-flex justify-center rounded-full border border-gray-400 p-1 transition-colors hover:border-transparent" + onClick={() => handleTagFilter(null)} + > + <Icon + icon={CLOSE_SVG} + className="inline-block h-2 w-2 text-gray-400 transition-colors group-hover:text-white" + /> + </button> + </div> + )} + + {/* set target/spf all features */} + <AllTargetsSelector + onChangeAllTargets={handleChangeAllTargets} + onChangeAllSPF={handleChangeAllSPF} + /> + + <div className="flex h-full flex-col overflow-hidden"> + <TargetsSPFTable + loading={selectedFeaturesQuery.isFetching} + data={list} + noDataMessage="No features found" + columns={TARGET_SPF_TABLE_COLUMNS} + sorting={filters.sort} + selectedIds={selectedFeatureIds} + onSortChange={handleSort} + onSelectAll={handleSelectAllFeatures} + onSelectRow={handleSelectFeature} + onSplitFeature={() => {}} + onToggleSeeOnMap={(id) => { + toggleSeeOnMap(id); + }} + onSelectTag={handleTagFilter} + ActionsComponent={ActionsMenu} + onChangeRow={handleRowValues} + onDeleteRow={handleRowDeletion} + /> + </div> + <Button + theme="primary" + size="lg" + type="button" + className="relative px-20" + // disabled={submitting} + onClick={onSubmit} + > + <span>Save</span> + </Button> + {displayBulkActions && ( + <FeaturesBulkActionMenu + features={targetedFeatures} + selectedFeatureIds={selectedFeatureIds} + /> + )} + </Section> + <ConfirmationPrompt + title={`Are you sure you want to change all feature targets to ${confirmationTarget}?`} + description="The action cannot be reverted." + open={!!confirmationTarget} + onAccept={onApplyAllTargets} + onRefuse={() => { + setConfirmationTarget(null); + }} + // ! review this later + onDismiss={() => { + // setConfirmationTarget(null); + }} + /> + <ConfirmationPrompt + title={`Are you sure you want to change all feature SPFs to ${confirmationFPF}?`} + description="The action cannot be reverted." + open={!!confirmationFPF} + onAccept={onApplyAllSPF} + onRefuse={() => setConfirmationFPF(null)} + // ! review this later + onDismiss={() => { + // setConfirmationTarget(null); + }} + /> + </> + ); +}; + +export default TargetAndSPFFeatures; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu/index.tsx new file mode 100644 index 0000000000..97320c3f64 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu/index.tsx @@ -0,0 +1,125 @@ +import { ComponentProps, useCallback, useState } from 'react'; + +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import EditModal from 'layout/project/sidebar/project/inventory-panel/features/modals/edit'; +import { cn } from 'utils/cn'; + +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; +import SPLIT_SVG from 'svgs/ui/split.svg?sprite'; +import TAG_SVG from 'svgs/ui/tag.svg?sprite'; + +import RowItem from '../row-item'; + +const BUTTON_CLASSES = + 'enabled:group flex w-full cursor-pointer items-center space-x-2 bg-gray-800 px-4 py-2 text-sm transition-colors enabled:hover:bg-gray-700'; + +const BUTTON_DISABLED_CLASSES = 'disabled:cursor-default disabled:text-gray-600'; + +const ICON_CLASSES = 'h-5 w-5 text-gray-100 group-hover:text-white'; + +const ICON_DISABLED_CLASSES = 'text-gray-700'; + +const ActionsMenu = ({ + item, + onSplitFeature, + onDeleteFeature, + onDismissMenu, +}: Parameters<ComponentProps<typeof RowItem>['ActionsComponent']>[0]): JSX.Element => { + const isDeletable = item.isCustom && !item.scenarios; + const isSplitable = Boolean(item.splitOptions?.length); + + const [modalState, setModalState] = useState<{ edit: boolean }>({ + edit: false, + }); + + const handleModal = useCallback( + (modalKey: keyof typeof modalState, isVisible: boolean) => { + setModalState((prevState) => { + if (!isVisible) onDismissMenu(); + return { ...prevState, [modalKey]: isVisible }; + }); + }, + [onDismissMenu] + ); + + return ( + <ul className="rounded-2xl border-gray-600"> + <li> + <button + type="button" + onClick={() => { + handleModal('edit', true); + }} + className={cn({ + [BUTTON_CLASSES]: true, + 'rounded-t-2xl': true, + })} + > + <Icon icon={TAG_SVG} className={ICON_CLASSES} /> + <span>Edit</span> + </button> + <Modal + id="edit-feature-modal" + title="All features" + open={modalState.edit} + size="narrow" + onDismiss={() => { + handleModal('edit', false); + }} + > + <EditModal featureId={item.id} handleModal={handleModal} /> + </Modal> + </li> + {isSplitable && ( + <li> + <button + type="button" + onClick={() => { + onSplitFeature(item.id); + }} + className={cn({ + [BUTTON_CLASSES]: true, + [BUTTON_DISABLED_CLASSES]: !isDeletable, + })} + disabled={!isDeletable} + > + <Icon + icon={SPLIT_SVG} + className={cn({ + [ICON_CLASSES]: true, + [ICON_DISABLED_CLASSES]: !isDeletable, + })} + /> + <span>Split</span> + </button> + </li> + )} + <li> + <button + type="button" + onClick={() => { + onDeleteFeature(item.id); + }} + className={cn({ + [BUTTON_CLASSES]: true, + 'rounded-b-2xl': true, + [BUTTON_DISABLED_CLASSES]: !isDeletable, + })} + disabled={!isDeletable} + > + <Icon + icon={DELETE_SVG} + className={cn({ + [ICON_CLASSES]: true, + [ICON_DISABLED_CLASSES]: !isDeletable, + })} + /> + <span>Delete</span> + </button> + </li> + </ul> + ); +}; + +export default ActionsMenu; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/header-item/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/header-item/index.tsx new file mode 100644 index 0000000000..3e73a23dd8 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/header-item/index.tsx @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; + +import { HiArrowUp, HiArrowDown } from 'react-icons/hi'; + +import { cn } from 'utils/cn'; + +const HeaderItem = ({ + className, + text, + name, + sorting, + onClick, +}: { + className?: string; + text: string; + name: string; + sorting: string; + onClick?: (field: string) => void; +}): JSX.Element => { + const sortingMatches = /^(-?)(.+)$/.exec(sorting); + const sortField = sortingMatches[2]; + const sortOrder = sortingMatches[1] === '-' ? 'desc' : 'asc'; + + const isActive = name === sortField; + + const handleClick = useCallback(() => { + onClick(name); + }, [onClick, name]); + + return ( + <button + type="button" + className={cn({ + 'inline-flex items-center space-x-2': true, + [className]: !!className, + })} + onClick={handleClick} + > + <span + className={cn({ + 'text-xs font-semibold uppercase leading-none text-gray-600': true, + 'text-white': isActive, + [className]: !!className, + })} + > + {text} + </span> + {sortOrder === 'asc' && isActive ? ( + <HiArrowDown + className={cn({ + 'h-5 w-5 text-gray-600': true, + 'text-blue-500': isActive, + })} + /> + ) : ( + <HiArrowUp + className={cn({ + 'h-5 w-5 text-gray-600': true, + 'text-blue-500': isActive, + })} + /> + )} + </button> + ); +}; + +export default HeaderItem; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx new file mode 100644 index 0000000000..954fca34c3 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx @@ -0,0 +1,123 @@ +import { useCallback, useState } from 'react'; + +import Checkbox from 'components/forms/checkbox'; +import Label from 'components/forms/label'; +import Loading from 'components/loading'; +import { ScrollArea } from 'components/scroll-area'; +import { cn } from 'utils/cn'; + +import HeaderItem from './header-item'; +import RowItem from './row-item'; +import { TargetsSPFTable } from './types'; + +const TargetsSPFTable = ({ + loading, + data, + noDataMessage, + columns, + sorting, + selectedIds, + onSortChange, + onSplitFeature, + onToggleSeeOnMap, + onSelectTag, + onSelectRow, + onSelectAll, + onChangeRow, + onDeleteRow, + ActionsComponent, +}: TargetsSPFTable): JSX.Element => { + const noData = !loading && data?.length === 0; + const [showDetails, setShowDetails] = useState(false); + + const noDataCustom = !loading && data?.every((item) => !item.isCustom); + + const handleShowDetails = useCallback(() => { + setShowDetails((prevShowDetails) => !prevShowDetails); + }, [setShowDetails]); + + return ( + <> + {loading && !data?.length && ( + <div className="relative min-h-[200px]"> + <Loading + visible={true} + className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" + /> + </div> + )} + {noData && <div className="flex h-[200px] items-center justify-center">{noDataMessage}</div>} + {!!data?.length && ( + <table className="relative flex h-full w-full flex-col space-y-2 overflow-hidden after:pointer-events-none after:absolute after:bottom-0 after:left-0 after:z-10 after:h-6 after:w-full after:bg-gradient-to-t after:from-gray-800 after:via-gray-800"> + <thead className="relative block text-left text-xs font-semibold uppercase before:pointer-events-none before:absolute before:left-0 before:top-full before:z-10 before:h-6 before:w-full before:bg-gradient-to-b before:from-gray-800 before:via-gray-800"> + <tr className="flex w-full items-center px-[10px]"> + <th> + <Checkbox + id="select-all" + theme="light" + className="block h-4 w-4 checked:bg-blue-500" + onChange={onSelectAll} + disabled={noDataCustom} + /> + </th> + {columns.map((column) => { + return ( + <th + key={column.name} + className={cn({ + 'flex-1 pl-2': true, + [column.className]: !!column.className, + })} + > + <HeaderItem + text={column.text} + name={column.name} + sorting={sorting} + onClick={onSortChange} + /> + </th> + ); + })} + <th className="flex-1 pl-2"> + <div className="flex space-x-2"> + <Checkbox + id="show-details" + theme="light" + className="block h-4 w-4 checked:bg-blue-500" + onChange={handleShowDetails} + /> + <Label id="show-details" className="text-xs normal-case text-white"> + Show details + </Label> + </div> + </th> + </tr> + </thead> + <ScrollArea className="h-full"> + <tbody className="block h-full divide-y divide-gray-600 pb-4 align-baseline text-sm"> + {data.map((item) => ( + <RowItem + key={item.id} + item={item} + selectedIds={selectedIds} + onSelectRow={onSelectRow} + onSplitFeature={onSplitFeature} + onToggleSeeOnMap={onToggleSeeOnMap} + showDetails={showDetails} + onClickTag={onSelectTag} + onChangeRow={onChangeRow} + onDeleteFeature={onDeleteRow} + ActionsComponent={ActionsComponent} + /> + ))} + </tbody> + </ScrollArea> + </table> + )} + </> + ); +}; + +export { type DataItem } from './types'; + +export default TargetsSPFTable; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/details/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/details/index.tsx new file mode 100644 index 0000000000..317f1301c0 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/details/index.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +import Input from 'components/forms/input'; + +export const RowDetails = ({ item, onChange }): JSX.Element => { + const { marxanSettings: { prop = 50, fpf = 1 } = {}, id } = item; + const [values, setValues] = useState({ + target: prop * 100, + spf: fpf, + }); + + // useEffect(() => { + // setValues({ + // target: prop * 100, + // spf: fpf, + // }); + // }, [prop, fpf]); + + return ( + <div className="flex w-full justify-end space-x-3"> + <div className="flex items-center space-x-2"> + <span>Target</span> + <Input + className="w-[55px] rounded-md border-solid border-gray-600 py-1 text-center" + theme="dark" + mode="dashed" + type="number" + defaultValue={values.target} + value={values.target} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + target: Number(inputValue), + })); + + onChange(id, { target: Number(inputValue) }); + }} + /> + <span className="text-xs">%</span> + </div> + <div className="flex items-center space-x-2"> + <span>SPF</span> + <Input + className="w-[55px] rounded border border-solid py-1 " + theme="dark" + mode="dashed" + type="number" + defaultValue={values.spf} + value={values.spf} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + spf: Number(inputValue), + })); + + onChange(id, { spf: Number(inputValue) }); + }} + /> + </div> + </div> + ); +}; + +export default RowDetails; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/index.tsx b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/index.tsx new file mode 100644 index 0000000000..754243ee32 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/index.tsx @@ -0,0 +1,179 @@ +import { useCallback, useRef, useState } from 'react'; + +import { HiDotsHorizontal } from 'react-icons/hi'; + +import Checkbox from 'components/forms/checkbox'; +import Icon from 'components/icon'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/popover'; +import { Feature } from 'types/api/feature'; +import { cn } from 'utils/cn'; + +import HIDE_SVG from 'svgs/ui/hide.svg?sprite'; +import SHOW_SVG from 'svgs/ui/show.svg?sprite'; + +import { DataItem, TargetsSPFTable } from '../types'; + +import RowDetails from './details'; + +const RowItem = ({ + item, + selectedIds, + onSelectRow, + onToggleSeeOnMap, + showDetails = true, + onClickTag, + ActionsComponent, + onChangeRow, + onDeleteFeature, +}: { + item: DataItem; + selectedIds: Feature['id'][]; + showDetails: boolean; + onSelectRow: TargetsSPFTable['onSelectRow']; + onToggleSeeOnMap: TargetsSPFTable['onToggleSeeOnMap']; + onSplitFeature: TargetsSPFTable['onSplitFeature']; + onClickTag: (tag: Feature['tag']) => void; + onChangeRow: () => void; + onDeleteFeature: (featureId: Feature['id']) => void; + ActionsComponent: ({ + item, + onDismissMenu, + onSplitFeature, + onDeleteFeature, + }: { + item: DataItem; + onDismissMenu: () => void; + onSplitFeature: (featureId: Feature['id']) => void; + onDeleteFeature: (featureId: Feature['id']) => void; + }) => JSX.Element; +}) => { + const { id, name, scenarios, type, marxanSettings, isVisibleOnMap, isCustom } = item; + const [isMenuOpen, setIsMenuOpen] = useState(false); + const buttonRef = useRef<HTMLButtonElement>(null); + const [featuresToSplit, setFeaturesToSplit] = useState<Feature['id'][]>([]); + + const onDismissMenu = useCallback(() => { + setIsMenuOpen(false); + }, []); + + const handleSplitFeature = useCallback((featureId: Feature['id']) => { + setFeaturesToSplit((prevFeaturesToSplit) => + prevFeaturesToSplit.includes(featureId) + ? prevFeaturesToSplit.filter((id) => id !== featureId) + : [...prevFeaturesToSplit, featureId] + ); + }, []); + + const handleFeatureDeletion = useCallback( + (featureId: Feature['id']) => { + onDeleteFeature(featureId); + }, + [onDeleteFeature] + ); + + return ( + <tr key={id} className="flex w-full flex-wrap px-[10px] py-2 align-top"> + <td className="pb-2 pr-1"> + <Checkbox + id={`select-${id}`} + theme="light" + className="block h-4 w-4 checked:bg-blue-500" + onChange={onSelectRow} + value={id} + checked={isCustom && selectedIds.includes(id)} + disabled={!isCustom} + /> + </td> + <td + className={cn({ + 'flex flex-col px-1 pb-2': true, + 'w-52': type, + })} + > + <span className="inline-flex">{name}</span> + {isCustom && ( + <div className="mt-1.5 text-xs text-gray-400"> + Currently in use in{' '} + <span className="rounded bg-blue-600 bg-opacity-10 px-1 text-blue-600"> + {scenarios} + </span>{' '} + scenarios. + </div> + )} + </td> + {type && ( + <td className="w-24 px-6 pb-2 pt-5 text-xs"> + <div className="flex justify-center"> + <button + type="button" + className="cursor-pointer whitespace-nowrap rounded-full bg-yellow-700 bg-opacity-10 px-2 py-1 text-yellow-700" + onClick={() => { + onClickTag(type); + }} + > + {type} + </button> + </div> + </td> + )} + <td className="w-22 ml-auto pb-2 pl-1 pr-2 pt-5"> + <div className="flex gap-6"> + <button type="button" onClick={() => onToggleSeeOnMap(id)}> + <Icon + className={cn({ + 'h-5 w-5 text-gray-600': true, + 'text-blue-500': isVisibleOnMap, + })} + icon={isVisibleOnMap ? SHOW_SVG : HIDE_SVG} + /> + </button> + + <Popover open={isMenuOpen}> + <PopoverTrigger asChild> + <button + type="button" + className={cn({ + 'h-5 w-5': true, + invisible: !isCustom, + })} + ref={buttonRef} + onClick={() => { + setIsMenuOpen((prevState) => !prevState); + }} + > + <HiDotsHorizontal className="pointer-events-none h-4 w-4 text-white" /> + </button> + </PopoverTrigger> + <PopoverContent + hideWhenDetached + className="w-auto rounded-2xl border-transparent p-0" + side="bottom" + sideOffset={5} + align="start" + onInteractOutside={(evt) => { + if (evt.target !== buttonRef.current) { + setIsMenuOpen(false); + } + }} + > + <ActionsComponent + item={item} + onDismissMenu={onDismissMenu} + onSplitFeature={handleSplitFeature} + onDeleteFeature={handleFeatureDeletion} + /> + </PopoverContent> + </Popover> + </div> + </td> + {showDetails && marxanSettings && ( + <td className="mt-2 flex w-full"> + <RowDetails item={item} onChange={onChangeRow} /> + </td> + )} + {featuresToSplit.includes(item.id) && <div>Split menu here</div>} + </tr> + ); +}; + +export default RowItem; diff --git a/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/types.ts b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/types.ts new file mode 100644 index 0000000000..691685e1be --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/types.ts @@ -0,0 +1,36 @@ +import { ChangeEvent } from 'react'; + +import { Feature } from 'types/api/feature'; +import { WDPA } from 'types/api/wdpa'; + +export type DataItem = { + id: string; + attributes?: Omit<WDPA, 'id'>; + name: string; + scenarios: number; + tag?: string; + isVisibleOnMap: boolean; + isCustom?: boolean; +}; + +export type TargetsSPFTable = { + loading: boolean; + data: DataItem[]; + noDataMessage: string; + columns: { + name: string; + text: string; + className?: string; + }[]; + sorting: string; + selectedIds: Feature['id'][]; + onSortChange: (field: string) => void; + onSplitFeature: (id: Feature['id']) => void; + onSelectTag: (tag: string) => void; + onToggleSeeOnMap: (id: Feature['id']) => void; + onSelectRow: (evt: ChangeEvent<HTMLInputElement>) => void; + onSelectAll: (evt: ChangeEvent<HTMLInputElement>) => void; + onDeleteRow: (featureId: Feature['id']) => void; + onChangeRow: (featureId: Feature['id'], values: Record<'prop' | 'spf', number>) => void; + ActionsComponent: ({ item }) => JSX.Element; +}; diff --git a/app/layout/scenarios/sidebar/component.tsx b/app/layout/scenarios/sidebar/component.tsx index dafb485047..2ed2bc951b 100644 --- a/app/layout/scenarios/sidebar/component.tsx +++ b/app/layout/scenarios/sidebar/component.tsx @@ -10,7 +10,7 @@ export const ScenariosEditSidebar = ({ children }: PropsWithChildren): JSX.Eleme <div className="flex w-full flex-grow flex-col overflow-hidden"> <Breadcrumbs /> <ScenarioHeader /> - <div className="mt-2.5 flex flex-grow flex-col overflow-hidden"> + <div className="flex flex-grow flex-col overflow-hidden"> <AnimatePresence>{children}</AnimatePresence> </div> </div>