From 2debda7db65916461a2c314bc394f59866aad9e0 Mon Sep 17 00:00:00 2001 From: barbara-chaves Date: Thu, 19 Oct 2023 15:20:06 +0200 Subject: [PATCH] Add map filters and state to app url params --- .../analysis-filters/indicators/component.tsx | 24 ++--- .../more-filters/component.tsx | 40 ++++++- .../analysis-map/component.tsx | 35 ++++-- client/src/containers/years/component.tsx | 19 +++- client/src/store/features/analysis/map.ts | 26 ++++- client/src/store/index.ts | 101 +++++++++++------- client/src/store/utils.ts | 56 ++++++++++ 7 files changed, 230 insertions(+), 71 deletions(-) create mode 100644 client/src/store/utils.ts diff --git a/client/src/containers/analysis-visualization/analysis-filters/indicators/component.tsx b/client/src/containers/analysis-visualization/analysis-filters/indicators/component.tsx index 45b325c33..15aa49fc6 100644 --- a/client/src/containers/analysis-visualization/analysis-filters/indicators/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-filters/indicators/component.tsx @@ -1,9 +1,9 @@ -import { useCallback, useMemo, useEffect } from 'react'; +import { useCallback, useMemo } from 'react'; import { useRouter } from 'next/router'; import { useAppDispatch, useAppSelector } from 'store/hooks'; import { analysisUI } from 'store/features/analysis/ui'; -import { analysisFilters, setFilter } from 'store/features/analysis/filters'; +import { setFilter } from 'store/features/analysis/filters'; import { useIndicators } from 'hooks/indicators'; import Select from 'components/forms/select'; @@ -17,10 +17,9 @@ const ALL = { }; const IndicatorsFilter = () => { - const { query = {}, replace } = useRouter(); + const { query = {} } = useRouter(); const { indicator } = query; const { visualizationMode } = useAppSelector(analysisUI); - const filters = useAppSelector(analysisFilters); const dispatch = useAppDispatch(); const { @@ -50,25 +49,16 @@ const IndicatorsFilter = () => { return selected || options?.[0]; }, [indicator, options]); - // Update the filter when the indicator changes - useEffect(() => { - if (current && filters.indicator?.value !== current.value) { + const handleChange: SelectProps['onChange'] = useCallback( + (selected: Option) => { dispatch( setFilter({ id: 'indicator', - value: current, + value: selected.value, }), ); - } - }, [current, dispatch, filters.indicator?.value]); - - const handleChange: SelectProps['onChange'] = useCallback( - (selected: Option) => { - replace({ query: { ...query, indicator: selected?.value } }, undefined, { - shallow: true, - }); }, - [query, replace], + [dispatch], ); return ( diff --git a/client/src/containers/analysis-visualization/analysis-filters/more-filters/component.tsx b/client/src/containers/analysis-visualization/analysis-filters/more-filters/component.tsx index 27a363c9f..c025d7009 100644 --- a/client/src/containers/analysis-visualization/analysis-filters/more-filters/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-filters/more-filters/component.tsx @@ -11,6 +11,7 @@ import { } from '@floating-ui/react'; import { Popover, Transition } from '@headlessui/react'; import { useRouter } from 'next/router'; +import { pickBy } from 'lodash-es'; import Materials from '../materials/component'; import OriginRegions from '../origin-regions/component'; @@ -63,7 +64,7 @@ const DEFAULT_QUERY_OPTIONS = { const MoreFilters = () => { const { query } = useRouter(); - const { scenarioId, compareScenarioId } = query; + const { scenarioId, compareScenarioId, ...restQueries } = query; const dispatch = useAppDispatch(); const { materials, origins, t1Suppliers, producers, locationTypes } = @@ -273,6 +274,43 @@ const MoreFilters = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [scenarioId]); + useEffect(() => { + const options = [ + materialOptions, + originOptions, + t1SupplierOptions, + producerOptions, + locationTypeOptions, + ]; + // Execute only when all options are loaded and there is no filters selected + if ( + options.some((option) => !option.length) || + Object.values(moreFilters).some((value) => value.length) + ) { + return; + } + + const { materials, origins, t1Suppliers, producers, locationTypes } = restQueries; + + const findOptions = (options: { label: string }[], filterQuery: string | string[]) => + options.filter((o) => { + return (Array.isArray(filterQuery) ? filterQuery : [filterQuery]).includes(o.label); + }); + + const initialFilters = { + materials: findOptions(materialOptions, materials), + origins: findOptions(originOptions, origins), + t1Suppliers: findOptions(t1SupplierOptions, t1Suppliers), + producers: findOptions(producerOptions, producers), + locationTypes: findOptions(locationTypeOptions, locationTypes), + }; + + const filtersToSave = pickBy(initialFilters, (value) => value.length); + + dispatch(setFilters(filtersToSave)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locationTypeOptions, materialOptions, originOptions, producerOptions, t1SupplierOptions]); + return ( {({ open, close }) => ( diff --git a/client/src/containers/analysis-visualization/analysis-map/component.tsx b/client/src/containers/analysis-visualization/analysis-map/component.tsx index b58332de2..4cd339718 100644 --- a/client/src/containers/analysis-visualization/analysis-map/component.tsx +++ b/client/src/containers/analysis-visualization/analysis-map/component.tsx @@ -1,10 +1,11 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { XCircleIcon } from '@heroicons/react/solid'; import { scaleByLegendType } from 'hooks/h3-data/utils'; import LayerManager from 'components/map/layer-manager'; -import { useAppSelector } from 'store/hooks'; +import { useAppDispatch, useAppSelector } from 'store/hooks'; import { analysisMap } from 'store/features/analysis'; +import { DEFAULT_MAP_STATE, setMapState } from 'store/features/analysis/map'; import { analysisUI } from 'store/features/analysis/ui'; import { useImpactLayer } from 'hooks/layers/impact'; import Legend from 'containers/analysis-visualization/analysis-legend'; @@ -13,7 +14,7 @@ import ZoomControl from 'components/map/controls/zoom'; import PopUp from 'components/map/popup'; import BasemapControl from 'components/map/controls/basemap'; import { NUMBER_FORMAT } from 'utils/number-format'; -import Map, { INITIAL_VIEW_STATE } from 'components/map'; +import Map from 'components/map'; import { getLayerConfig } from 'components/map/layers/utils'; import type { LayerConstructor } from 'components/map/layers/utils'; @@ -22,6 +23,7 @@ import type { ViewState } from 'react-map-gl'; import type { MapStyle } from 'components/map/types'; import type { BasemapValue } from 'components/map/controls/basemap/types'; import type { Layer, Legend as LegendType } from 'types'; +import { useRouter } from 'next/router'; const getLegendScale = (legendInfo: LegendType) => { if (legendInfo?.type === 'range' || legendInfo?.type === 'category') { @@ -38,12 +40,17 @@ const getLegendScale = (legendInfo: LegendType) => { }; const AnalysisMap = () => { - const { layers } = useAppSelector(analysisMap); + const { query } = useRouter(); + const { layers, mapState } = useAppSelector(analysisMap); const { isSidebarCollapsed } = useAppSelector(analysisUI); + const dispatch = useAppDispatch(); const [mapStyle, setMapStyle] = useState('terrain'); - const [viewState, setViewState] = useState>(INITIAL_VIEW_STATE); - const handleViewState = useCallback((viewState: ViewState) => setViewState(viewState), []); + + const handleViewState = useCallback((viewState: ViewState) => { + dispatch(setMapState(viewState)); + }, []); + const [tooltipData, setTooltipData] = useState(null); // Pre-Calculating legend scales @@ -97,13 +104,27 @@ const AnalysisMap = () => { .map((layer) => ({ id: layer.id, layer: getLayerConfig(layers[layer.id]), props: layer })); }, [layers]); + useEffect(() => { + const { longitude, latitude, zoom } = query; + if (longitude && latitude && zoom) { + dispatch( + setMapState({ + ...DEFAULT_MAP_STATE, + longitude: Number(longitude), + latitude: Number(latitude), + zoom: Number(zoom), + }), + ); + } + }, []); + return (
{isFetching && } { const dispatch = useAppDispatch(); const filters = useAppSelector(analysisFilters); const { layer, materials, indicator, startYear } = filters; + const { query = {} } = useRouter(); const materialsIds = useMemo(() => materials.map((mat) => mat.value), [materials]); const { data: years, isLoading } = useYears(layer, materialsIds, indicator?.value, { @@ -42,14 +44,21 @@ const YearsFilter: React.FC = () => { [dispatch], ); + useEffect(() => { + const initialStartYear = query.startYear; + if (initialStartYear) { + dispatch(setFilter({ id: 'startYear', value: initialStartYear })); + } + }, []); + // Update filters when data changes useEffect(() => { if (years?.length && !isLoading) { - dispatch( - setFilters({ ...(startYear ? {} : { startYear: years[years.length - 1] }), endYear: null }), - ); + if (years.includes(Number(startYear))) return; + const lastYearValue = years[years.length - 1]; + dispatch(setFilter({ id: 'startYear', value: lastYearValue })); } - }, [dispatch, isLoading, years, startYear]); + }, [dispatch, isLoading, startYear, years]); return ( diff --git a/client/src/store/features/analysis/map.ts b/client/src/store/features/analysis/map.ts index 6b7b31370..fb57f53aa 100644 --- a/client/src/store/features/analysis/map.ts +++ b/client/src/store/features/analysis/map.ts @@ -1,9 +1,19 @@ import { createSlice } from '@reduxjs/toolkit'; +import type { ViewState } from 'react-map-gl'; import type { PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from 'store'; import type { Layer } from 'types'; +export const DEFAULT_MAP_STATE: Partial = { + longitude: 0, + latitude: 0, + zoom: 2, + pitch: 0, + bearing: 0, + padding: null, +}; + const DEFAULT_LAYER_ATTRIBUTES = { order: 0, visible: false, @@ -54,6 +64,7 @@ export type AnalysisMapState = { }; // Deck.gl layer props by layer id layerDeckGLProps: Record>; + mapState: Partial; }; // Define the initial state using that type @@ -79,12 +90,17 @@ export const initialState: AnalysisMapState = { y: 0, }, layerDeckGLProps: {}, + mapState: DEFAULT_MAP_STATE, }; export const analysisMapSlice = createSlice({ name: 'analysisMap', initialState, reducers: { + setMapState: (state, action: PayloadAction) => ({ + ...state, + mapState: action.payload, + }), setLayer: ( state, action: PayloadAction<{ @@ -180,8 +196,14 @@ export const analysisMapSlice = createSlice({ }, }); -export const { setLayer, setLayerDeckGLProps, setTooltipData, setTooltipPosition, setLayerOrder } = - analysisMapSlice.actions; +export const { + setLayer, + setLayerDeckGLProps, + setTooltipData, + setTooltipPosition, + setLayerOrder, + setMapState, +} = analysisMapSlice.actions; export const analysisMap = (state: RootState): AnalysisMapState => state['analysis/map']; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index ec5b173a4..316362980 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -1,6 +1,8 @@ import { configureStore, combineReducers } from '@reduxjs/toolkit'; import router from 'next/router'; -import { cloneDeep, isObject } from 'lodash-es'; +import { cloneDeep, isObject, mapValues, pickBy, omit } from 'lodash-es'; + +import { formatParam, routerReplace, routerReplaceMany } from './utils'; import ui from 'store/features/ui'; import analysisUI, { @@ -71,25 +73,15 @@ const QUERY_PARAMS_MAP: QueryParams = { }, }; -const formatParam = (param: string): string | boolean | number => { - if (['true', 'false'].includes(param)) return param === 'true'; - if (!Number.isNaN(Number(param))) return Number(param); - if (checkValidJSON(param)) { - const obj = JSON.parse(param); - if (typeof obj === 'string') return param; - return obj; - } - return param; -}; - -const checkValidJSON = (json: string) => { - try { - isObject(JSON.parse(json)); - return true; - } catch (e) { - return false; - } -}; +const FILTER_QUERY_PARAMS = [ + 'indicator', + 'startYear', + 'materials', + 'origins', + 't1Suppliers', + 'producers', + 'locationTypes', +]; const getPreloadedState = ( query: Record, @@ -119,31 +111,62 @@ const querySyncMiddleware: Middleware = () => (next) => (action) => { const { query, isReady } = router; if (!isReady) return next(action); + // FILTERS + if (action.type.includes('analysisFilters')) { + if (action.type === 'analysisFilters/setFilter') { + const { id, value } = action.payload; + if (FILTER_QUERY_PARAMS.includes(id)) { + const { [id]: currentQueryValue, ...restQueryParams } = query; + const queryValue = isObject(value) ? (value as any).value : String(value); + if (currentQueryValue !== value) { + routerReplace({ + queryName: id, + queryValue: queryValue, + restQueries: restQueryParams, + }); + } + } + } + // ACTIONS THAT SET MULTIPLE FILTERS + else if ( + action.type === 'analysisFilters/resetFilter' || + action.type === 'analysisFilters/setFilters' + ) { + const otherParams = omit(query, FILTER_QUERY_PARAMS); + let filterParams = {}; + if (action.type === 'analysisFilters/setFilters') { + const payloadQueries = pickBy(action.payload, (value, key) => + FILTER_QUERY_PARAMS.includes(key) && Array.isArray(value) ? value.length : !!value, + ); + filterParams = mapValues(payloadQueries, (value) => + Array.isArray(value) ? value.map((v) => v.label) : value, + ); + } + routerReplaceMany(filterParams, otherParams); + } + return next(action); + } + + if (action.type === 'analysisMap/setMapState') { + const otherParams = omit(query, Object.keys(['latitude', 'longitude', 'zoom'])); + const { latitude, longitude, zoom } = action.payload; + const newParams = pickBy({ latitude, longitude, zoom }, (value) => !!value); + routerReplaceMany(newParams, otherParams); + } + + // OTHER PARAMS Object.entries(QUERY_PARAMS_MAP).forEach(async ([param, queryState]) => { if (action.type === queryState.action.type) { const { [param]: currentQueryValue, ...queryWithoutParam } = query; - const currentStateValue = action.payload; + const currentStateValue = action.payload; // Only update when URL the param value is different if (currentQueryValue !== currentStateValue) { - await router.replace( - { - query: { - ...queryWithoutParam, - ...(!!currentStateValue - ? { - [param]: ['string', 'number'].includes(typeof currentStateValue) - ? currentStateValue - : checkValidJSON(JSON.stringify(currentStateValue)) - ? JSON.stringify(currentStateValue) - : currentStateValue, - } - : {}), - }, - }, - null, - { shallow: true }, - ); + await routerReplace({ + queryName: param, + queryValue: currentStateValue, + restQueries: queryWithoutParam, + }); } } }); diff --git a/client/src/store/utils.ts b/client/src/store/utils.ts new file mode 100644 index 000000000..528606a05 --- /dev/null +++ b/client/src/store/utils.ts @@ -0,0 +1,56 @@ +import { isObject } from 'lodash-es'; +import router from 'next/router'; + +export const checkValidJSON = (json: string) => { + try { + isObject(JSON.parse(json)); + return true; + } catch (e) { + return false; + } +}; + +export const formatParam = (param: string): string | boolean | number => { + if (['true', 'false'].includes(param)) return param === 'true'; + if (!Number.isNaN(Number(param))) return Number(param); + if (checkValidJSON(param)) { + const obj = JSON.parse(param); + if (typeof obj === 'string') return param; + return obj; + } + return param; +}; + +export const routerReplace = async ({ queryName, queryValue, restQueries }) => { + await router.replace( + { + query: { + ...restQueries, + ...(!!queryName + ? { + [queryName]: ['string', 'number'].includes(typeof queryValue) + ? queryValue + : checkValidJSON(JSON.stringify(queryValue)) + ? JSON.stringify(queryValue) + : queryValue, + } + : {}), + }, + }, + null, + { shallow: true }, + ); +}; + +export const routerReplaceMany = async (newParams, otherParams) => { + await router.replace( + { + query: { + ...otherParams, + ...newParams, + }, + }, + null, + { shallow: true }, + ); +};