From ea6f8cfca48b93cb61b4f8d2d727e7dbcf5b578e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez?= Date: Thu, 9 May 2024 19:31:36 +0200 Subject: [PATCH] WIP --- .../analysis-filters/component.tsx | 18 +- .../analysis-filters/indicators/index.tsx | 208 ++++++++---------- .../analysis-filters/indicators/map/index.tsx | 143 ++++++++++++ .../analysis-table/component.tsx | 55 +++-- client/src/utils/indicator-param.ts | 23 +- 5 files changed, 303 insertions(+), 144 deletions(-) create mode 100644 client/src/containers/analysis-visualization/analysis-filters/indicators/map/index.tsx diff --git a/client/src/containers/analysis-visualization/analysis-filters/component.tsx b/client/src/containers/analysis-visualization/analysis-filters/component.tsx index 57ca300c2..c6aca4034 100644 --- a/client/src/containers/analysis-visualization/analysis-filters/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-filters/component.tsx @@ -1,4 +1,5 @@ import IndicatorsFilter from './indicators'; +import IndicatorsMapFilter from './indicators/map'; import GroupByFilter from './group-by'; import YearsRangeFilter from './years-range'; import MoreFilters from './more-filters'; @@ -12,10 +13,19 @@ const AnalysisFilters: React.FC = () => { return (
- - {visualizationMode !== 'map' && } - {visualizationMode === 'map' && } - {visualizationMode !== 'map' && } + {visualizationMode === 'map' && ( + <> + + + + )} + {visualizationMode !== 'map' && ( + <> + + + + + )}
); diff --git a/client/src/containers/analysis-visualization/analysis-filters/indicators/index.tsx b/client/src/containers/analysis-visualization/analysis-filters/indicators/index.tsx index e7dbb4b08..ed883dd0e 100644 --- a/client/src/containers/analysis-visualization/analysis-filters/indicators/index.tsx +++ b/client/src/containers/analysis-visualization/analysis-filters/indicators/index.tsx @@ -1,151 +1,123 @@ -import { useCallback, useMemo, useEffect, ComponentProps, Fragment, useState } from 'react'; +import { useCallback, useMemo, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; +import queryString from 'query-string'; +import { useSearchParams } from 'next/navigation'; -import { useAppDispatch, useAppSelector } from 'store/hooks'; -import { analysisUI } from 'store/features/analysis/ui'; -import { setFilter } from 'store/features/analysis/filters'; import { useIndicators } from 'hooks/indicators'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, - SelectLabel, - SelectSeparator, -} from '@/components/ui/select'; +import TreeSelect from '@/components/tree-select'; +import { TreeSelectOption } from '@/components/tree-select/types'; +import { flattenTree } from '@/components/tree-select/utils'; + +type HasParentProperty = T & { isParent?: boolean }; const IndicatorsFilter = () => { const { query = {}, replace } = useRouter(); - const { indicator } = query; - const { visualizationMode } = useAppSelector(analysisUI); - const dispatch = useAppDispatch(); - const [value, setValue] = useState(undefined); + const searchParams = useSearchParams(); + const [selectedOptions, setSelectedOptions] = useState(undefined); - const { data, isFetching, isFetched } = useIndicators( - { sort: 'name' }, + const { data, isFetching } = useIndicators( + { sort: 'category' }, { select: (data) => data?.data, }, ); - const options = useMemo(() => { - const categories = Array.from( - new Set(data?.map(({ category }) => category).filter(Boolean)), - ).sort((a, b) => a.localeCompare(b)); + const options = useMemo(() => { + const categories = Array.from(new Set(data?.map(({ category }) => category).filter(Boolean))); const categoryGroups = categories.map((category) => { const indicators = data?.filter((indicator) => indicator.category === category); - const categoryOptions = indicators.map((indicator) => ({ - label: indicator.name, - value: indicator.id, - disabled: indicator.status === 'inactive', - })); - return { label: category, value: category, options: categoryOptions }; + const categoryOptions = indicators.map( + (indicator) => + ({ + label: indicator.name, + value: indicator.id, + // disabled: indicator.status === 'inactive', + }) satisfies TreeSelectOption<(typeof indicator)['id']>, + ); + return { + label: category, + value: category, + isParent: true, + children: categoryOptions, + } satisfies HasParentProperty>; }); - return [ - ...(visualizationMode !== 'map' - ? [ - { - label: 'All indicators', - value: 'all', - options: [], - }, - ] - : []), - ...categoryGroups, - ]; - }, [data, visualizationMode]); + return categoryGroups; + }, [data]); - const indicatorName = useMemo(() => { - const indicator = data?.find((indicator) => indicator.id === value); - return indicator?.name; - }, [data, value]); + const allNodes = useMemo(() => options?.flatMap((opt) => flattenTree(opt)), [options]); const handleChange = useCallback( - (value: Parameters['onValueChange']>[0]) => { - replace({ query: { ...query, indicator: value } }, undefined, { - shallow: true, - }); - - dispatch( - setFilter({ - id: 'indicator', - value: { - label: indicatorName, - value, - }, - }), + (options: HasParentProperty[]) => { + const _v = options + .map(({ value, isParent }) => { + if (isParent) { + return allNodes + .filter(({ value: v }) => v === value) + .map(({ children }) => children.map(({ value }) => value)) + .flat(1); + } + return value; + }) + .flat(1); + + const { indicators, ...restQuery } = query; + + setSelectedOptions(options); + + replace( + { + query: queryString.stringify( + { ...restQuery, indicators: _v }, + { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }, + ), + }, + undefined, + { + shallow: true, + }, ); }, - [query, replace, indicatorName, dispatch], + [query, replace, allNodes], ); - useEffect(() => { - if (indicator) { - return setValue(indicator as string); - } - - if (visualizationMode === 'map') { - if (options?.at(0)?.options?.length) { - return setValue(options.at(0).options.at(0).value); - } - } + const parsedQueryParams = useMemo(() => { + return queryString.parse(searchParams.toString(), { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }); + }, [searchParams]); - if (options?.length && !options?.at(0)?.options?.length) { - return setValue(options.at(0).value); + const initialSelectedOptions = useMemo(() => { + if (parsedQueryParams.indicators && options?.length) { + const selectedOptions = allNodes.filter(({ value }) => + parsedQueryParams.indicators.includes(value), + ); + return selectedOptions; } - }, [visualizationMode, options, indicator, indicatorName, dispatch]); + return undefined; + }, [parsedQueryParams, options, allNodes]); useEffect(() => { - if (indicator && indicatorName) { - dispatch( - setFilter({ - id: 'indicator', - value: { - label: indicatorName, - value: indicator, - }, - }), - ); - } - }, [dispatch, indicator, indicatorName]); + setSelectedOptions(initialSelectedOptions); + }, [initialSelectedOptions]); return ( - +
+ +
); }; diff --git a/client/src/containers/analysis-visualization/analysis-filters/indicators/map/index.tsx b/client/src/containers/analysis-visualization/analysis-filters/indicators/map/index.tsx new file mode 100644 index 000000000..92026db45 --- /dev/null +++ b/client/src/containers/analysis-visualization/analysis-filters/indicators/map/index.tsx @@ -0,0 +1,143 @@ +import { useCallback, useMemo, useEffect, ComponentProps, Fragment, useState } from 'react'; +import { useRouter } from 'next/router'; +import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/solid'; + +import { useAppDispatch } from 'store/hooks'; +import { setFilter } from 'store/features/analysis/filters'; +import { useIndicators } from 'hooks/indicators'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + SelectLabel, + SelectSeparator, +} from '@/components/ui/select'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; + +const IndicatorsMapFilter = () => { + const { query = {}, replace } = useRouter(); + const { indicator } = query; + const dispatch = useAppDispatch(); + const [value, setValue] = useState(undefined); + + const { data, isFetching, isFetched } = useIndicators( + { sort: 'name' }, + { + select: (data) => data?.data, + }, + ); + + const options = useMemo(() => { + const categories = Array.from( + new Set(data?.map(({ category }) => category).filter(Boolean)), + ).sort((a, b) => a.localeCompare(b)); + + const categoryGroups = categories.map((category) => { + const indicators = data?.filter((indicator) => indicator.category === category); + const categoryOptions = indicators.map((indicator) => ({ + label: indicator.name, + value: indicator.id, + disabled: indicator.status === 'inactive', + })); + return { label: category, value: category, options: categoryOptions }; + }); + + return categoryGroups; + }, [data]); + + const indicatorName = useMemo(() => { + const indicator = data?.find((indicator) => indicator.id === value); + return indicator?.name; + }, [data, value]); + + const handleChange = useCallback( + (value: Parameters['onValueChange']>[0]) => { + replace({ query: { ...query, indicator: value } }, undefined, { + shallow: true, + }); + + dispatch( + setFilter({ + id: 'indicator', + value: { + label: indicatorName, + value, + }, + }), + ); + }, + [query, replace, indicatorName, dispatch], + ); + + useEffect(() => { + if (indicator) { + return setValue(indicator as string); + } + + return setValue(options?.at(0)?.options?.at(0)?.value); + }, [options, indicator, indicatorName, dispatch]); + + useEffect(() => { + if (indicator && indicatorName) { + dispatch( + setFilter({ + id: 'indicator', + value: { + label: indicatorName, + value: indicator, + }, + }), + ); + } + }, [dispatch, indicator, indicatorName]); + + return ( + + ); +}; + +export default IndicatorsMapFilter; diff --git a/client/src/containers/analysis-visualization/analysis-table/component.tsx b/client/src/containers/analysis-visualization/analysis-table/component.tsx index e097ab612..c48a1c0f3 100644 --- a/client/src/containers/analysis-visualization/analysis-table/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-table/component.tsx @@ -3,6 +3,8 @@ import { DownloadIcon, InformationCircleIcon } from '@heroicons/react/outline'; import { uniq, omit } from 'lodash-es'; import toast from 'react-hot-toast'; import { ArrowLeftIcon } from '@heroicons/react/solid'; +import queryString from 'query-string'; +import { useSearchParams } from 'next/navigation'; import ComparisonCell from './comparison-cell/component'; import ChartCell from './chart-cell'; @@ -62,10 +64,20 @@ const AnalysisTable = () => { }; }, [expandedState, paginationState, rowSelectionState, sortingState]); + const searchParams = useSearchParams(); + const parsedQueryParams = useMemo(() => { + return queryString.parse(searchParams.toString(), { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }); + }, [searchParams]); + + const selectedIndicators = parsedQueryParams?.indicators; + const { scenarioToCompare, isComparisonEnabled, currentScenario } = useAppSelector(scenarios); const { data: indicators } = useIndicators( { 'filter[status]': 'active' }, - { select: (data) => data.data }, + { select: (data) => data?.data }, ); const downloadImpactData = useDownloadImpactData({ onSuccess: () => { @@ -104,22 +116,25 @@ const AnalysisTable = () => { [isComparisonEnabled, currentScenario], ); - const { indicatorId, ...restFilters } = filters; + const restFilters = filters; const isEnable = - !!indicatorId && !!indicators?.length && !!filters.startYear && !!filters.endYear && filters.endYear !== filters.startYear; const indicatorIds = useMemo(() => { - if (indicatorId === 'all') { - return indicators.map((indicator) => indicator.id); + if (Array.isArray(selectedIndicators)) { + return selectedIndicators.map((id) => id); + } + + if (selectedIndicators && !Array.isArray(selectedIndicators)) { + return [selectedIndicators]; } - if (indicatorId) return [indicatorId]; - return []; - }, [indicators, indicatorId]); + + return indicators.map((indicator) => indicator.id); + }, [indicators, selectedIndicators]); const sortingParams = useMemo(() => { if (!!sortingState.length) { @@ -293,7 +308,7 @@ const AnalysisTable = () => { const [tableData, setTableData] = useState[]>([]); useEffect(() => { - if (indicatorId !== 'all') { + if (selectedIndicators) { // A single indicator is selected so we force its expansion setTableData(initialTableData[0]?.children); } else { @@ -302,13 +317,13 @@ const AnalysisTable = () => { } setExpandedState(null); setRowSelectionState({}); - }, [indicatorId, initialTableData]); + }, [selectedIndicators, initialTableData]); const setIndicatorParam = useIndicatorParam(); const handleExitExpanded = useCallback(() => { setExpandedState({}); - setIndicatorParam('all'); + setIndicatorParam(undefined); }, [setIndicatorParam]); const handleExpandRow = useCallback( @@ -329,10 +344,14 @@ const AnalysisTable = () => { [isComparison, isScenarioComparison], ); - const expanded = useMemo( - () => (indicatorId === 'all' ? null : indicators.find((i) => i.id === indicatorId)), - [indicatorId, indicators], - ); + const expanded = useMemo(() => { + const indicatorIds = Array.isArray(selectedIndicators) + ? selectedIndicators + : [selectedIndicators]; + return !Boolean(selectedIndicators) + ? null + : indicators.find((i) => indicatorIds.includes(i.id)); + }, [selectedIndicators, indicators]); const comparisonColumn = useCallback( (year: number): ColumnDefinition> => { @@ -490,7 +509,7 @@ const AnalysisTable = () => { (): TableProps> & { firstProjectedYear: number; } => ({ - showPagination: indicatorId !== 'all', + showPagination: Boolean(selectedIndicators), paginationProps: { totalItems: metadata.totalItems, totalPages: metadata.totalPages, @@ -503,7 +522,7 @@ const AnalysisTable = () => { onPaginationChange: setPaginationState, onExpandedChange: setExpandedState, isLoading: isFetching, - enableExpanding: indicatorId !== 'all', + enableExpanding: Boolean(selectedIndicators), data: (tableData as ImpactRowType[]) || [], columns: baseColumns as ColumnDefinition>[], handleExpandedChange, @@ -513,11 +532,11 @@ const AnalysisTable = () => { metadata, tableState, isFetching, - indicatorId, tableData, baseColumns, handleExpandedChange, firstProjectedYear, + selectedIndicators, ], ); diff --git a/client/src/utils/indicator-param.ts b/client/src/utils/indicator-param.ts index d78084d8d..cabe9db17 100644 --- a/client/src/utils/indicator-param.ts +++ b/client/src/utils/indicator-param.ts @@ -1,10 +1,25 @@ import { useRouter } from 'next/router'; +import queryString from 'query-string'; export const useIndicatorParam = () => { const { query = {}, replace } = useRouter(); - return (indicator: string) => { - replace({ query: { ...query, indicator } }, undefined, { - shallow: true, - }); + return (indicator: string | string[] | undefined) => { + const { indicators, ...queryWithoutIndicators } = query; + + replace( + { + query: queryString.stringify( + { ...queryWithoutIndicators, ...(Boolean(indicator) && { indicators: indicator }) }, + { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }, + ), + }, + undefined, + { + shallow: true, + }, + ); }; };