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..d06f83c786 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(); @@ -191,7 +191,9 @@ export function useSelectedFeatures( }, }).then(({ data }) => data); - return useQuery(['selected-features', sid], fetchFeatures, { + console.log({ filters }); + + return useQuery(['selected-features', sid, filters], fetchFeatures, { ...queryOptions, enabled: !!sid && ((featureColorQueryState && featureColorQueryState.status === 'success') || true), @@ -270,7 +272,8 @@ export function useSelectedFeatures( ...d, id: featureId, name: alias || featureClassName, - type: tag, + // todo: remove test later + type: tag ?? 'test', description, amountRange: { min: amountMin, @@ -291,18 +294,45 @@ 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; }); } - return orderBy(parsedData, ['type', 'name'], ['asc', 'asc']); + 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; }, placeholderData: { data: {} as GeoFeatureSet }, }); @@ -314,7 +344,7 @@ export function useTargetedFeatures( queryOptions = {} ) { const { data: session } = useSession(); - const { search } = filters; + const { search, sort } = filters; const fetchFeatures = () => SCENARIOS.request({ @@ -325,10 +355,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 +441,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 +470,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 +489,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 +570,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..bef1c65b6a --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/all-targets/index.tsx @@ -0,0 +1,125 @@ +import { useRef, useState } from 'react'; + +import Input from 'components/forms/input'; + +const DEFAULT_INPUT_VALUES = { + target: 50, + spf: 1, +}; + +const AllTargetsSelector = (): JSX.Element => { + const targetInputRef = useRef<HTMLInputElement>(); + const spfInputRef = useRef<HTMLInputElement>(); + const [values, setValues] = useState(DEFAULT_INPUT_VALUES); + // const [FPFValue, setFPFValue] = useState(fpf || defaultFPF); + // const [inputFPFValue, setInputFPFValue] = useState(String(FPFValue)); + + return ( + <div className="grid grid-cols-4 gap-2 rounded-lg bg-gray-700 px-[10px] py-[5px] text-xs"> + <div className="col-span-2"> + <span className="text-white">Set target and SPF in all features:</span> + </div> + <div className="col-span-1 flex items-center space-x-2"> + <span>Target</span> + <Input + className="rounded px-0 py-1 " + theme="dark" + mode="dashed" + type="number" + defaultValue={values.target} + // value={inputFPFValue} + // disabled={!editable} + onReady={(input) => { + 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) => ({ + ...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: 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(); + } + } + }} + /> + % + </div> + <div className="col-span-1 flex items-center space-x-2"> + <span>SPPF</span> + <Input + className="rounded px-0 py-1 " + theme="dark" + mode="dashed" + type="number" + defaultValue={values.spf} + // value={inputFPFValue} + // disabled={!editable} + onReady={(input) => { + 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(); + + if (spfInputRef.current) { + spfInputRef.current.blur(); + } + } + }} + /> + </div> + </div> + ); +}; + +export default AllTargetsSelector; 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..44810a75d9 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/index.tsx @@ -0,0 +1,202 @@ +import { ComponentProps, useCallback, useMemo, useState } from 'react'; + +import { useRouter } from 'next/router'; + +import { useAppDispatch, useAppSelector } from 'store/hooks'; +import { getScenarioEditSlice } from 'store/slices/scenarios/edit'; + +import { useDebouncedCallback } from 'use-debounce'; + +import { useSelectedFeatures } from 'hooks/features'; + +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 AllTargetsSelector from './all-targets'; + +const TARGET_SPF_TABLE_COLUMNS = [ + { + name: 'name', + text: 'Name', + }, + { + name: 'type', + text: 'Type', + }, +] satisfies ComponentProps<typeof TargetsSPFTable>['columns']; + +const TargetAndSPFFeatures = (): JSX.Element => { + const { query } = useRouter(); + const { sid } = query as { pid: string; sid: string }; + const [filters, setFilters] = useState({ + sort: TARGET_SPF_TABLE_COLUMNS[0].name, + search: null, + tag: null, + }); + const dispatch = useAppDispatch(); + + const scenarioSlice = getScenarioEditSlice(sid); + const { setSelectedFeatures, setSelectedContinuousFeatures, setLayerSettings } = + scenarioSlice.actions; + const { selectedFeatures, selectedContinuousFeatures } = useAppSelector( + (state) => state[`/scenarios/${sid}/edit`] + ); + + console.log('filters comp', filters); + 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((tag: Feature['tag']) => { + setFilters((prevFilters) => ({ + ...prevFilters, + tag, + })); + }, []); + + const toggleSeeOnMap = useCallback( + (id: Feature['id']) => { + const binaryFeatures = [...selectedFeatures]; + const continuousFeatures = [...selectedContinuousFeatures]; + + const isIncludedInBinary = binaryFeatures.includes(id); + const isIncludedInContinuous = continuousFeatures.includes(id); + + const feature = selectedFeaturesQuery.data?.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 = selectedFeaturesQuery.data?.find(({ featureId }) => featureId === id); + const { color } = selectedFeature || {}; + + dispatch( + setLayerSettings({ + id, + settings: { + visibility: !(isIncludedInBinary || isIncludedInContinuous), + color, + ...(isContinuous && { + amountRange: feature.amountRange, + }), + }, + }) + ); + }, + [ + dispatch, + setSelectedFeatures, + setLayerSettings, + selectedFeatures, + selectedContinuousFeatures, + setSelectedContinuousFeatures, + selectedFeaturesQuery.data, + ] + ); + + const targetedFeatures = useMemo( + () => + selectedFeaturesQuery.data?.map((feature) => ({ + ...feature, + isVisibleOnMap: selectedFeatures.includes(feature.id), + isCustom: feature.metadata?.isCustom, + })), + [selectedFeaturesQuery.data, selectedFeatures] + ); + + console.log(selectedFeaturesQuery.data); + + 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" + /> + + {/* set target/spf all features */} + <AllTargetsSelector /> + + <div className="flex h-full flex-col overflow-hidden"> + <TargetsSPFTable + loading={selectedFeaturesQuery.isFetching} + data={targetedFeatures} + noDataMessage="No features found" + columns={TARGET_SPF_TABLE_COLUMNS} + sorting={filters.sort} + selectedIds={[]} + onSortChange={handleSort} + onSelectAll={() => {}} + onSelectRow={() => {}} + onSplitFeature={() => {}} + onToggleSeeOnMap={(id) => { + toggleSeeOnMap(id); + }} + onSelectTag={handleTagFilter} + ActionsComponent={ActionsMenu} + /> + </div> + </Section> + ); +}; + +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..0e2668358a --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/actions-menu/index.tsx @@ -0,0 +1,137 @@ +import { ComponentProps, 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 { 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, + 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; delete: boolean }>({ + edit: false, + delete: 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={() => { + handleModal('delete', true); + }} + 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> + <Modal + id="delete-feature-modal" + dismissable + open={modalState.delete} + size="narrow" + onDismiss={() => { + handleModal('delete', false); + }} + > + <DeleteModal selectedFeaturesIds={[item.id]} /> + </Modal> + </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..fb36d9abe8 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/index.tsx @@ -0,0 +1,119 @@ +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, + 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 pl-1"> + <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 pl-1 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} + 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..156f8d4332 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/details/index.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; + +import Input from 'components/forms/input'; + +import { DataItem } from '../../types'; + +export const RowDetails = ({ item }): JSX.Element => { + const { target, fpf }: { target: number; fpf: number } = item; + const [values, setValues] = useState({ + target, + spf: fpf, + }); + + return ( + <div className="flex w-full justify-end"> + <div className="space-x-2"> + <div className="flex flex-grow-0 flex-wrap items-center space-x-2"> + <span>Target</span> + <Input + className="w-auto rounded px-0 py-1" + theme="dark" + mode="dashed" + type="number" + defaultValue={values.target} + // value={inputFPFValue} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + target: Number(inputValue), + })); + }} + /> + <span>%</span> + </div> + <div className="flex items-center space-x-2"> + <span>SPF</span> + <Input + className="rounded px-0 py-1 " + theme="dark" + mode="dashed" + type="number" + defaultValue={values.spf} + // value={inputFPFValue} + // disabled={!editable} + onChange={({ target: { value: inputValue } }) => { + setValues((prevValues) => ({ + ...prevValues, + spf: Number(inputValue), + })); + }} + /> + </div> + </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..bf48449e39 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/row-item/index.tsx @@ -0,0 +1,165 @@ +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, +}: { + item: DataItem; + selectedIds: Feature['id'][]; + showDetails: boolean; + onSelectRow: TargetsSPFTable['onSelectRow']; + onToggleSeeOnMap: TargetsSPFTable['onToggleSeeOnMap']; + onSplitFeature: TargetsSPFTable['onSplitFeature']; + onClickTag: (tag: Feature['tag']) => void; + ActionsComponent: ({ + item, + onDismissMenu, + onSplitFeature, + }: { + item: DataItem; + onDismissMenu: () => void; + onSplitFeature: (featureId: Feature['id']) => void; + }) => JSX.Element; +}) => { + const { id, name, scenarios, type, 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] + ); + }, []); + + return ( + <tr key={id} className="flex w-full flex-wrap 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-28 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} + /> + </PopoverContent> + </Popover> + </div> + </td> + {showDetails && ( + <td className="flex w-full"> + <RowDetails item={item} /> + </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..e9848cf1a1 --- /dev/null +++ b/app/layout/project/sidebar/scenario/grid-setup/features/target-spf/targets-spf-table/types.ts @@ -0,0 +1,34 @@ +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; + 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>