From a3ee02fe9282a98463ed8226d5220839d44d42ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 11 Dec 2023 12:09:38 +0100 Subject: [PATCH] WIP --- app/hooks/features/index.ts | 37 +- .../features/target-spf/all-targets/index.tsx | 215 ++++++------ .../target-spf/bulk-action-menu/index.tsx | 63 ++++ .../grid-setup/features/target-spf/index.tsx | 323 ++++++++++++++---- .../target-spf/targets-spf-table/index.tsx | 4 +- .../row-item/details/index.tsx | 82 +++-- .../targets-spf-table/row-item/index.tsx | 10 +- 7 files changed, 495 insertions(+), 239 deletions(-) create mode 100644 app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx diff --git a/app/hooks/features/index.ts b/app/hooks/features/index.ts index d06f83c786..537f2f24ae 100644 --- a/app/hooks/features/index.ts +++ b/app/hooks/features/index.ts @@ -191,9 +191,7 @@ export function useSelectedFeatures( }, }).then(({ data }) => data); - console.log({ filters }); - - return useQuery(['selected-features', sid, filters], fetchFeatures, { + return useQuery(['selected-features', sid], fetchFeatures, { ...queryOptions, enabled: !!sid && ((featureColorQueryState && featureColorQueryState.status === 'success') || true), @@ -272,8 +270,8 @@ export function useSelectedFeatures( ...d, id: featureId, name: alias || featureClassName, - // todo: remove test later - type: tag ?? 'test', + type: tag, + // type: Math.random() < 0.5 ? 'test' : 'andres', description, amountRange: { min: amountMin, @@ -305,34 +303,7 @@ export function useSelectedFeatures( }); } - console.log({ tag }); - if (tag) { - const fuse = new Fuse(parsedData, { - keys: ['type'], - threshold: 0.25, - }); - - parsedData = fuse.search(tag).map((f) => { - return f.item; - }); - } - // } - - console.log({ parsedData }); - - // Sort - 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]); - }); - } - - return parsedData; + return orderBy(parsedData, ['type', 'name'], ['asc', 'asc']); }, placeholderData: { data: {} as GeoFeatureSet }, }); 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 index bef1c65b6a..a9ed5bbc04 100644 --- 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 @@ -7,7 +7,16 @@ const DEFAULT_INPUT_VALUES = { spf: 1, }; -const AllTargetsSelector = (): JSX.Element => { +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 targetInputRef = useRef(); const spfInputRef = useRef(); const [values, setValues] = useState(DEFAULT_INPUT_VALUES); @@ -15,108 +24,120 @@ const AllTargetsSelector = (): JSX.Element => { // const [inputFPFValue, setInputFPFValue] = useState(String(FPFValue)); return ( -
-
- Set target and SPF in all features: -
-
- Target - { - targetInputRef.current = input; - }} - onChange={({ target: { value: inputValue } }) => { - // setInputFPFValue(inputValue); - setValues((prevValues) => ({ - ...prevValues, - target: Number(inputValue), - })); - }} - onBlur={() => { - // If user leaves the input empty, we'll revert to the original targetValue - if (!values.target) { - return setValues((prevValues) => ({ +
+ + Set target and SPF in all features: + +
+
+ Target + { + targetInputRef.current = input; + }} + onChange={({ target: { value: inputValue } }) => { + // setInputFPFValue(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 + if (!values.target) { + return setValues((prevValues) => ({ + ...prevValues, + target: DEFAULT_INPUT_VALUES.target, + })); + } + // Prevent changing all targets if user didn't actually change it + // (despite clicking on the input) + // if (FPFValue === Number(inputFPFValue)) return; + setValues((prevValues) => ({ ...prevValues, - target: DEFAULT_INPUT_VALUES.target, + target: values.target, })); - } - // Prevent changing all targets if user didn't actually change it - // (despite clicking on the input) - // if (FPFValue === Number(inputFPFValue)) return; - setValues((prevValues) => ({ - ...prevValues, - target: values.target, - })); - // if (onChangeFPF) onChangeFPF(Number(inputFPFValue)); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation(); - event.nativeEvent.stopPropagation(); - event.nativeEvent.preventDefault(); - if (targetInputRef.current) { - targetInputRef.current.blur(); + // onChangeAllTargets(Number(values.target)); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + event.nativeEvent.stopPropagation(); + event.nativeEvent.preventDefault(); + + if (targetInputRef.current) { + targetInputRef.current.blur(); + } } - } - }} - /> - % -
-
- SPPF - { - spfInputRef.current = input; - }} - onChange={({ target: { value: inputValue } }) => { - setValues((prevValues) => ({ - ...prevValues, - spf: Number(inputValue), - })); - }} - // onBlur={() => { - // // If user leaves the input empty, we'll revert to the original targetValue - // if (!inputFPFValue) { - // setInputFPFValue(String(FPFValue)); - // return; - // } - // // Prevent changing all targets if user didn't actually change it - // // (despite clicking on the input) - // // if (FPFValue === Number(inputFPFValue)) return; - // setFPFValue(Number(inputFPFValue)); - // if (onChangeFPF) onChangeFPF(Number(inputFPFValue)); - // }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation(); - event.nativeEvent.stopPropagation(); - event.nativeEvent.preventDefault(); + }} + /> + % +
+
+ SPF + { + spfInputRef.current = input; + }} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + spf: Number(inputValue), + })); + + onChangeAllSPF(Number(inputValue)); + }} + // onBlur={() => { + // // If user leaves the input empty, we'll revert to the original targetValue + // if (!inputFPFValue) { + // setInputFPFValue(String(FPFValue)); + // return; + // } + // // Prevent changing all targets if user didn't actually change it + // // (despite clicking on the input) + // // if (FPFValue === Number(inputFPFValue)) return; + // setFPFValue(Number(inputFPFValue)); + // if (onChangeFPF) onChangeFPF(Number(inputFPFValue)); + // }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + event.nativeEvent.stopPropagation(); + event.nativeEvent.preventDefault(); - if (spfInputRef.current) { - spfInputRef.current.blur(); + if (spfInputRef.current) { + spfInputRef.current.blur(); + } } - } - }} - /> + }} + /> +
); 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..2c7d437c28 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/bulk-action-menu/index.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; + +import Button from 'components/button'; +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import DeleteModal from 'layout/project/sidebar/project/inventory-panel/features/modals/delete'; +import { Feature } from 'types/api/feature'; + +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; + +const BUTTON_CLASSES = + 'col-span-1 flex items-center space-x-2 rounded-lg bg-gray-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 = ({ + selectedFeatureIds, +}: { + 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 ( + <> +
+ + + {selectedFeatureIds.length} + + Selected + + + +
+ + handleModal('delete', false)} + > + + + + ); +}; + +export default SplitFeaturesBulkActionMenu; 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 index 44810a75d9..b5265b64b9 100644 --- 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 @@ -1,16 +1,20 @@ -import { ComponentProps, useCallback, useMemo, useState } from 'react'; +import { ChangeEvent, ComponentProps, useCallback, useEffect, useMemo, useState } from 'react'; 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 { useSelectedFeatures } from 'hooks/features'; +import ConfirmationPrompt from 'components/confirmation-prompt'; +import Icon from 'components/icon'; import InfoButton from 'components/info-button'; import Search from 'components/search'; +import FeaturesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu'; 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'; @@ -18,6 +22,8 @@ import ActionsMenu from 'layout/project/sidebar/scenario/grid-setup/features/tar 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'; const TARGET_SPF_TABLE_COLUMNS = [ @@ -37,8 +43,21 @@ const TargetAndSPFFeatures = (): JSX.Element => { const [filters, setFilters] = useState({ sort: TARGET_SPF_TABLE_COLUMNS[0].name, search: null, - tag: null, + type: null, }); + const [featureValues, setFeatureValues] = useState< + Record< + string, + { + target: number; + spf: number; + } + > + >({}); + const [confirmationTarget, setConfirmationTarget] = useState(null); + const [confirmationFPF, setConfirmationFPF] = useState(null); + const [selectedFeatureIds, setSelectedFeatureIds] = useState([]); + const dispatch = useAppDispatch(); const scenarioSlice = getScenarioEditSlice(sid); @@ -48,7 +67,6 @@ const TargetAndSPFFeatures = (): JSX.Element => { (state) => state[`/scenarios/${sid}/edit`] ); - console.log('filters comp', filters); const selectedFeaturesQuery = useSelectedFeatures(sid, filters, { keepPreviousData: true, }); @@ -74,10 +92,10 @@ const TargetAndSPFFeatures = (): JSX.Element => { [filters.sort] ); - const handleTagFilter = useCallback((tag: Feature['tag']) => { + const handleTagFilter = useCallback((type: Feature['tag']) => { setFilters((prevFilters) => ({ ...prevFilters, - tag, + type, })); }, []); @@ -89,7 +107,7 @@ const TargetAndSPFFeatures = (): JSX.Element => { const isIncludedInBinary = binaryFeatures.includes(id); const isIncludedInContinuous = continuousFeatures.includes(id); - const feature = selectedFeaturesQuery.data?.find(({ id: featureId }) => featureId === id); + const feature = targetedFeatures.find(({ id: featureId }) => featureId === id); const isContinuous = feature.amountRange.min !== null && feature.amountRange.max !== null; if (isContinuous) { @@ -111,7 +129,7 @@ const TargetAndSPFFeatures = (): JSX.Element => { dispatch(setSelectedFeatures(binaryFeatures)); } - const selectedFeature = selectedFeaturesQuery.data?.find(({ featureId }) => featureId === id); + const selectedFeature = targetedFeatures.find(({ id: featureId }) => featureId === id); const { color } = selectedFeature || {}; dispatch( @@ -138,64 +156,251 @@ const TargetAndSPFFeatures = (): JSX.Element => { ] ); - const targetedFeatures = useMemo( - () => - selectedFeaturesQuery.data?.map((feature) => ({ - ...feature, - isVisibleOnMap: selectedFeatures.includes(feature.id), - isCustom: feature.metadata?.isCustom, - })), - [selectedFeaturesQuery.data, selectedFeatures] + const handleChangeAllTargets = useCallback( + (target: Parameters['onChangeAllTargets']>[0]) => { + setConfirmationTarget(target); + }, + [] ); - console.log(selectedFeaturesQuery.data); + const handleChangeAllSPF = useCallback( + (spf: Parameters['onChangeAllSPF']>[0]) => { + setConfirmationFPF(spf); + }, + [] + ); + + const targetedFeatures = useMemo(() => { + let parsedData = []; + // ? renders the split features as well + selectedFeaturesQuery.data?.forEach((feature) => { + if (feature.splitFeaturesSelected?.length > 0) { + const splitFeatures = feature.splitFeaturesSelected.map((splitFeature) => ({ + ...feature, + ...splitFeature, + name: `${feature.name} / ${splitFeature.name}`, + isVisibleOnMap: selectedFeatures.includes(splitFeature.id), + isCustom: feature.metadata?.isCustom, + marxanSettings: { + ...splitFeature.marxanSettings, + ...(featureValues[splitFeature.id]?.target && { + prop: featureValues[splitFeature.id].target / 100, + }), + ...(featureValues[splitFeature.id]?.spf && { + fpf: featureValues[feature.id].spf / 100, + }), + }, + })); + + parsedData = [...parsedData, ...splitFeatures]; + } + + parsedData = [ + ...parsedData, + { + ...feature, + isVisibleOnMap: selectedFeatures.includes(feature.id), + isCustom: feature.metadata?.isCustom, + marxanSettings: { + ...feature.marxanSettings, + ...(featureValues[feature.id]?.target && { + prop: featureValues[feature.id].target / 100, + }), + ...(featureValues[feature.id]?.spf && { fpf: featureValues[feature.id].spf / 100 }), + }, + }, + ]; + }); + + 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(() => { + const newValues = targetedFeatures.reduce( + (acc, feature) => ({ + ...acc, + [feature.id]: { + ...targetedFeatures[feature.id]?.marxanSettings, + target: confirmationTarget, + }, + }), + {} + ); + + setFeatureValues(newValues); + setConfirmationTarget(null); + }, [confirmationTarget, targetedFeatures]); + + const onApplyAllSPF = useCallback(() => { + const newValues = targetedFeatures.reduce( + (acc, feature) => ({ + ...acc, + [feature.id]: { + ...featureValues[feature.id], + spf: confirmationFPF, + }, + }), + {} + ); + + setFeatureValues(newValues); + setConfirmationFPF(null); + }, [confirmationFPF, targetedFeatures, featureValues]); + + const handleSelectAllFeatures = useCallback( + (evt: ChangeEvent) => { + if (evt.target.checked) { + setSelectedFeatureIds(targetedFeatures.map(({ id }) => id)); + } else { + setSelectedFeatureIds([]); + } + }, + [targetedFeatures] + ); + + const handleSelectFeature = useCallback((evt: ChangeEvent) => { + if (evt.target.checked) { + setSelectedFeatureIds((prevFeatureIds) => [...prevFeatureIds, evt.target.value]); + } else { + setSelectedFeatureIds((prevFeatureIds) => + prevFeatureIds.filter((featureId) => featureId !== evt.target.value) + ); + } + }, []); + + // useEffect(() => { + // setFeatureValues( + // targetedFeatures.reduce( + // (acc, feature) => ({ + // ...acc, + // [feature.id]: { + // target: feature.marxanSettings?.prop * 100, + // spf: feature.marxanSettings?.fpf, + // }, + // }), + // {} + // ) + // ); + // }, [targetedFeatures]); + + console.log({ featureValues, targetedFeatures }); + + const displayBulkActions = selectedFeatureIds.length > 0; return ( -
-
-
- Inventory Panel -

- Features - - - -

-
- -
- + <> +
+
+
+ Inventory Panel +

+ Features + + + +

+
+ +
+ + {filters.type && ( +
+ + Filtering by: + + + +
+ )} - {/* set target/spf all features */} - - -
- {}} - onSelectRow={() => {}} - onSplitFeature={() => {}} - onToggleSeeOnMap={(id) => { - toggleSeeOnMap(id); - }} - onSelectTag={handleTagFilter} - ActionsComponent={ActionsMenu} + {/* set target/spf all features */} + -
-
+ +
+ {}} + onToggleSeeOnMap={(id) => { + toggleSeeOnMap(id); + }} + onSelectTag={handleTagFilter} + ActionsComponent={ActionsMenu} + /> +
+ {displayBulkActions && } +
+ setConfirmationTarget(null)} + onDismiss={() => setConfirmationTarget(null)} + /> + setConfirmationFPF(null)} + onDismiss={() => setConfirmationFPF(null)} + /> + ); }; 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 index fb36d9abe8..4bfa75b5e1 100644 --- 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 @@ -48,7 +48,7 @@ const TargetsSPFTable = ({ {!!data?.length && ( - + - + {data.map((item) => ( { - const { target, fpf }: { target: number; fpf: number } = item; + const { marxanSettings: { prop = 50, fpf = 1 } = {} } = item; const [values, setValues] = useState({ - target, + target: prop * 100, spf: fpf, }); return ( -
-
-
- Target - { - setValues((prevValues) => ({ - ...prevValues, - target: Number(inputValue), - })); - }} - /> - % -
-
- SPF - { - setValues((prevValues) => ({ - ...prevValues, - spf: Number(inputValue), - })); - }} - /> -
+
+
+ Target + { + setValues((prevValues) => ({ + ...prevValues, + target: Number(inputValue), + })); + }} + /> + % +
+
+ SPF + { + setValues((prevValues) => ({ + ...prevValues, + spf: Number(inputValue), + })); + }} + />
); 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 index bf48449e39..7f8d989766 100644 --- 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 @@ -41,7 +41,7 @@ const RowItem = ({ onSplitFeature: (featureId: Feature['id']) => void; }) => JSX.Element; }) => { - const { id, name, scenarios, type, isVisibleOnMap, isCustom } = item; + const { id, name, scenarios, type, marxanSettings, isVisibleOnMap, isCustom } = item; const [isMenuOpen, setIsMenuOpen] = useState(false); const buttonRef = useRef(null); const [featuresToSplit, setFeaturesToSplit] = useState([]); @@ -59,7 +59,7 @@ const RowItem = ({ }, []); return ( -
+ - {showDetails && ( - )}
{type && ( - +
+ {showDetails && marxanSettings && ( +