From 8b3de568ee4ecf2ed9f751281bb0755a9839891a 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 --- client/.eslintrc.cjs | 2 +- .../analysis-dynamic-metadata/component.tsx | 2 +- .../analysis-filters/component.tsx | 18 +- .../analysis-filters/indicators/index.tsx | 218 ++++++++---------- .../analysis-filters/indicators/map/index.tsx | 143 ++++++++++++ .../analysis-table/component.tsx | 68 ++++-- .../analysis-table/types.d.ts | 1 + client/src/utils/indicator-param.ts | 25 +- 8 files changed, 328 insertions(+), 149 deletions(-) create mode 100644 client/src/containers/analysis-visualization/analysis-filters/indicators/map/index.tsx diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index 8f39e003b..155b018fd 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -7,7 +7,7 @@ module.exports = { ], plugins: ['prettier'], rules: { - '@typescript-eslint/no-unused-vars': ['warn'], + '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }], 'import/order': [ 'warn', { diff --git a/client/src/containers/analysis-visualization/analysis-dynamic-metadata/component.tsx b/client/src/containers/analysis-visualization/analysis-dynamic-metadata/component.tsx index a726b1f19..390ebb4ce 100644 --- a/client/src/containers/analysis-visualization/analysis-dynamic-metadata/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-dynamic-metadata/component.tsx @@ -194,7 +194,7 @@ const AnalysisDynamicMetadata: FC = ({ Viewing {isComparisonEnabled ? : values} - Impact values for + impact values for {scenario1} {comparisonTemplate} 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..48bbe7b73 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,133 @@ -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, usePathname } from 'next/navigation'; +import { xor } from 'lodash-es'; -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 isTableView = usePathname().includes('/table'); + 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, detail, ...restQuery } = query; + + setSelectedOptions(options); + + const optionDetailRemoved = xor(selectedOptions, options).some( + ({ value }) => value === detail, + ); + + replace( + { + query: queryString.stringify( + { + ...restQuery, + indicators: _v, + ...(isTableView && optionDetailRemoved && { detail: undefined }), + }, + { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }, + ), + }, + undefined, + { + shallow: true, + }, ); }, - [query, replace, indicatorName, dispatch], + [query, replace, allNodes, selectedOptions, isTableView], ); - 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..09278d21c 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,21 @@ 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 as string[]; + const indicatorDetail = parsedQueryParams?.detail as string; + 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 +117,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,22 +309,30 @@ const AnalysisTable = () => { const [tableData, setTableData] = useState[]>([]); useEffect(() => { - if (indicatorId !== 'all') { - // A single indicator is selected so we force its expansion - setTableData(initialTableData[0]?.children); - } else { - // All indicators are selected and no indicator is expanded + // ? a single indicator is expanded + if (indicatorDetail) { + setTableData(initialTableData.find((row) => row.indicatorId === indicatorDetail)?.children); + } + + // ? several indicators are selected + if (selectedIndicators && !indicatorDetail) { + setTableData(initialTableData.filter((row) => selectedIndicators.includes(row.indicatorId))); + } + + // ? all indicators are selected + if (!selectedIndicators?.length && !indicatorDetail) { setTableData(initialTableData); } + setExpandedState(null); setRowSelectionState({}); - }, [indicatorId, initialTableData]); + }, [selectedIndicators, initialTableData, indicatorDetail]); const setIndicatorParam = useIndicatorParam(); const handleExitExpanded = useCallback(() => { setExpandedState({}); - setIndicatorParam('all'); + setIndicatorParam(undefined); }, [setIndicatorParam]); const handleExpandRow = useCallback( @@ -329,10 +353,9 @@ const AnalysisTable = () => { [isComparison, isScenarioComparison], ); - const expanded = useMemo( - () => (indicatorId === 'all' ? null : indicators.find((i) => i.id === indicatorId)), - [indicatorId, indicators], - ); + const expanded = useMemo(() => { + return indicators.find((i) => i.id === indicatorDetail); + }, [indicatorDetail, indicators]); const comparisonColumn = useCallback( (year: number): ColumnDefinition> => { @@ -490,7 +513,7 @@ const AnalysisTable = () => { (): TableProps> & { firstProjectedYear: number; } => ({ - showPagination: indicatorId !== 'all', + showPagination: Boolean(selectedIndicators), paginationProps: { totalItems: metadata.totalItems, totalPages: metadata.totalPages, @@ -503,7 +526,7 @@ const AnalysisTable = () => { onPaginationChange: setPaginationState, onExpandedChange: setExpandedState, isLoading: isFetching, - enableExpanding: indicatorId !== 'all', + enableExpanding: Boolean(indicatorDetail), data: (tableData as ImpactRowType[]) || [], columns: baseColumns as ColumnDefinition>[], handleExpandedChange, @@ -513,11 +536,12 @@ const AnalysisTable = () => { metadata, tableState, isFetching, - indicatorId, tableData, baseColumns, handleExpandedChange, firstProjectedYear, + selectedIndicators, + indicatorDetail, ], ); diff --git a/client/src/containers/analysis-visualization/analysis-table/types.d.ts b/client/src/containers/analysis-visualization/analysis-table/types.d.ts index 5c3fcb06c..d81761694 100644 --- a/client/src/containers/analysis-visualization/analysis-table/types.d.ts +++ b/client/src/containers/analysis-visualization/analysis-table/types.d.ts @@ -40,6 +40,7 @@ export type ImpactRowType< children?: this[]; name: string; values: ImpactTableValueItem[]; + indicatorId?: string; }; export type ImpactTableValueItem = Comparison extends false diff --git a/client/src/utils/indicator-param.ts b/client/src/utils/indicator-param.ts index d78084d8d..efd9d11d5 100644 --- a/client/src/utils/indicator-param.ts +++ b/client/src/utils/indicator-param.ts @@ -1,10 +1,29 @@ 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, - }); + // const { indicators, ...queryWithoutIndicators } = query; + + replace( + { + query: queryString.stringify( + { + ...query, + detail: indicator, + // ...(Boolean(indicator) && { indicators: indicator }) + }, + { + arrayFormat: 'comma', + arrayFormatSeparator: ',', + }, + ), + }, + undefined, + { + shallow: true, + }, + ); }; };