diff --git a/app/components/dashboard-interactive-filters.tsx b/app/components/dashboard-interactive-filters.tsx index e3dde53fe..739556369 100644 --- a/app/components/dashboard-interactive-filters.tsx +++ b/app/components/dashboard-interactive-filters.tsx @@ -6,7 +6,6 @@ import { } from "@mui/material"; import { Theme } from "@mui/material/styles"; import { makeStyles } from "@mui/styles"; -import uniqBy from "lodash/uniqBy"; import { useEffect, useMemo, useState } from "react"; import { @@ -19,8 +18,8 @@ import { timeUnitToParser, } from "@/configurator/components/ui-helpers"; import { canDimensionBeTimeFiltered } from "@/domain/data"; -import { useDataCubesComponentsQuery } from "@/graphql/hooks"; -import { DataCubeComponentFilter, TimeUnit } from "@/graphql/query-hooks"; +import { useConfigsCubeComponents } from "@/graphql/hooks"; +import { TimeUnit } from "@/graphql/query-hooks"; import { useLocale } from "@/src"; import { SharedFilter, @@ -90,35 +89,21 @@ const DashboardTimeRangeSlider = ({ const dashboardInteractiveFilters = useDashboardInteractiveFilters(); const [state] = useConfiguratorState(hasChartConfigs); - const source = state.dataSource; const locale = useLocale(); - const cubeFilters = useMemo(() => { - return uniqBy( - state.chartConfigs.flatMap((chartConfig) => - chartConfig.cubes.map((x: DataCubeComponentFilter) => ({ - iri: x.iri, - componentIris: [filter.componentIri], - joinBy: x.joinBy, - })) - ), - (x) => x.iri - ); - }, [filter.componentIri, state.chartConfigs]); - - const [data] = useDataCubesComponentsQuery({ + const [data] = useConfigsCubeComponents({ variables: { - sourceUrl: source.url, - sourceType: source.type, - cubeFilters, + state, locale, }, }); const timeUnit = useMemo(() => { - const dim = data?.data?.dataCubesComponents?.dimensions?.[0]; + const dim = data?.data?.dataCubesComponents?.dimensions?.find( + (d) => d.iri === filter.componentIri + ); return canDimensionBeTimeFiltered(dim) ? dim.timeUnit : undefined; - }, [data?.data?.dataCubesComponents?.dimensions]); + }, [data?.data?.dataCubesComponents?.dimensions, filter.componentIri]); const presets = filter.presets; assert( diff --git a/app/configurator/components/layout-configurator.tsx b/app/configurator/components/layout-configurator.tsx index 5b133669d..685be3a53 100644 --- a/app/configurator/components/layout-configurator.tsx +++ b/app/configurator/components/layout-configurator.tsx @@ -1,4 +1,4 @@ -import { Trans, t } from "@lingui/macro"; +import { t, Trans } from "@lingui/macro"; import { Box, FormControlLabel, @@ -11,8 +11,7 @@ import { import capitalize from "lodash/capitalize"; import keyBy from "lodash/keyBy"; import omit from "lodash/omit"; -import uniqBy from "lodash/uniqBy"; -import { useMemo } from "react"; +import { Fragment, useMemo } from "react"; import { DataFilterGenericDimensionProps } from "@/charts/shared/chart-data-filters"; import { Select } from "@/components/form"; @@ -25,9 +24,9 @@ import { SubsectionTitle, } from "@/configurator/components/chart-controls/section"; import { + canRenderDatePickerField, DatePickerField, DatePickerFieldProps, - canRenderDatePickerField, } from "@/configurator/components/field-date-picker"; import { IconButton } from "@/configurator/components/icon-button"; import { @@ -39,15 +38,15 @@ import { useConfiguratorState, } from "@/configurator/configurator-state"; import { + canDimensionBeTimeFiltered, Dimension, + isJoinByComponent, TemporalDimension, TemporalEntityDimension, - canDimensionBeTimeFiltered, - isJoinByComponent, } from "@/domain/data"; import { useFlag } from "@/flags"; import { useTimeFormatLocale, useTimeFormatUnit } from "@/formatters"; -import { useDataCubesComponentsQuery } from "@/graphql/hooks"; +import { useConfigsCubeComponents } from "@/graphql/hooks"; import { useLocale } from "@/src"; import { SharedFilter, @@ -114,31 +113,18 @@ const LayoutSharedFiltersConfigurator = () => { const { layout } = state; const { sharedFilters, potentialSharedFilters } = useDashboardInteractiveFilters(); + const locale = useLocale(); - const cubeFilters = useMemo(() => { - return uniqBy( - state.chartConfigs.flatMap((config) => - config.cubes.map((x) => ({ - iri: x.iri, - joinBy: x.joinBy, - loadValues: true, - })) - ), - "iri" - ); - }, [state.chartConfigs]); - const [data] = useDataCubesComponentsQuery({ + const [{ data }] = useConfigsCubeComponents({ variables: { - sourceType: state.dataSource.type, - sourceUrl: state.dataSource.url, + state, locale: locale, - cubeFilters: cubeFilters, }, }); const dimensionsByIri = useMemo(() => { const res: Record = {}; - for (const dim of data.data?.dataCubesComponents.dimensions ?? []) { + for (const dim of data?.dataCubesComponents.dimensions ?? []) { res[dim.iri] = dim; if (isJoinByComponent(dim)) { for (const o of dim.originalIris) { @@ -147,7 +133,7 @@ const LayoutSharedFiltersConfigurator = () => { } } return res; - }, [data.data?.dataCubesComponents.dimensions]); + }, [data?.dataCubesComponents.dimensions]); const sharedFiltersByIri = useMemo(() => { return keyBy(sharedFilters, (x) => x.componentIri); @@ -211,6 +197,14 @@ const LayoutSharedFiltersConfigurator = () => { switch (layout.type) { case "tab": case "dashboard": + const shownFilters = potentialSharedFilters.filter((filter) => { + const dimension = dimensionsByIri[filter.componentIri]; + return dimension && canDimensionBeTimeFiltered(dimension); + }); + + if (!shownFilters.length) { + return null; + } return ( { - {potentialSharedFilters.map((filter) => { + {shownFilters.map((filter) => { const dimension = dimensionsByIri[filter.componentIri]; const sharedFilter = sharedFiltersByIri[filter.componentIri]; - - if (!dimension || !canDimensionBeTimeFiltered(dimension)) { - return null; - } return ( - <> + { sharedFilter={sharedFilter} dimension={dimension} /> - + ); })} diff --git a/app/domain/user-configs.ts b/app/domain/user-configs.ts index 859b37515..82541365f 100644 --- a/app/domain/user-configs.ts +++ b/app/domain/user-configs.ts @@ -2,21 +2,29 @@ import { useCallback } from "react"; import { ParsedConfig } from "@/db/config"; import { fetchChartConfig, fetchChartConfigs } from "@/utils/chart-config/api"; -import { UseFetchDataOptions, useFetchData } from "@/utils/use-fetch-data"; +import { useFetchData, UseFetchDataOptions } from "@/utils/use-fetch-data"; export const userConfigsKey = ["userConfigs"]; const userConfigKey = (t: string) => ["userConfigs", t]; export const useUserConfigs = (options?: UseFetchDataOptions) => - useFetchData(userConfigsKey, fetchChartConfigs, options); + useFetchData({ + queryKey: userConfigsKey, + queryFn: fetchChartConfigs, + options, + }); export const useUserConfig = ( chartId: string | undefined, options?: UseFetchDataOptions ) => { let queryFn = useCallback(() => fetchChartConfig(chartId ?? ""), [chartId]); - return useFetchData(userConfigKey(chartId!), queryFn, { - enable: !!chartId, - ...options, + return useFetchData({ + queryKey: userConfigKey(chartId!), + queryFn, + options: { + enable: !!chartId, + ...options, + }, }); }; diff --git a/app/graphql/hooks.spec.tsx b/app/graphql/hooks.spec.tsx index 2f64d5c3e..14c24604c 100644 --- a/app/graphql/hooks.spec.tsx +++ b/app/graphql/hooks.spec.tsx @@ -15,7 +15,9 @@ describe("makeUseQuery", () => { }); }); - const useMockQuery = makeUseQuery(mockQuery); + const useMockQuery = makeUseQuery({ + fetch: mockQuery, + }); afterEach(() => { mockQuery.mockClear(); diff --git a/app/graphql/hooks.ts b/app/graphql/hooks.ts index 9eea03cc1..95c32fa73 100644 --- a/app/graphql/hooks.ts +++ b/app/graphql/hooks.ts @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Client, useClient } from "urql"; +import { ConfiguratorState, hasChartConfigs } from "@/configurator"; import { DataCubeComponents, DataCubeMetadata, DataCubesObservations, } from "@/domain/data"; +import { Locale } from "@/locales/locales"; import { assert } from "@/utils/assert"; import { joinDimensions, mergeObservations } from "./join"; @@ -37,8 +39,12 @@ export const makeUseQuery = pause?: boolean; }, V, - >( - executeQueryFn: ( + >({ + fetch, + check, + }: { + check?: (variables: T["variables"]) => void; + fetch: ( client: Client, options: T["variables"], onFetching?: () => void @@ -46,8 +52,8 @@ export const makeUseQuery = data?: V; error?: Error; fetching: boolean; - }> - ) => + }>; + }) => (options: T & { keepPreviousData?: boolean }) => { const client = useClient(); const [result, setResult] = useState<{ @@ -58,6 +64,10 @@ export const makeUseQuery = }>({ fetching: !options.pause, queryKey: null, data: null }); const { keepPreviousData } = options; const queryKey = useQueryKey(options); + + if (!options.pause && check) { + check(options.variables); + } const executeQuery = useCallback( async (options: T) => { setResult((prev) => ({ @@ -67,7 +77,10 @@ export const makeUseQuery = prev.queryKey === queryKey || keepPreviousData ? prev.data : null, queryKey, })); - const result = await executeQueryFn(client, options.variables, () => { + if (check) { + check(options.variables); + } + const result = await fetch(client, options.variables, () => { setResult((prev) => ({ ...prev, fetching: true, @@ -102,7 +115,7 @@ type DataCubesMetadataData = { dataCubesMetadata: DataCubeMetadata[]; }; -const executeDataCubesMetadataQuery = async ( +export const executeDataCubesMetadataQuery = async ( client: Client, variables: DataCubesMetadataOptions["variables"], /** Callback triggered when data fetching starts (cache miss). */ @@ -148,7 +161,9 @@ const executeDataCubesMetadataQuery = async ( export const useDataCubesMetadataQuery = makeUseQuery< DataCubesMetadataOptions, DataCubesMetadataData ->(executeDataCubesMetadataQuery); +>({ + fetch: executeDataCubesMetadataQuery, +}); export type DataCubesComponentsOptions = { variables: Omit & { @@ -170,6 +185,7 @@ export const executeDataCubesComponentsQuery = async ( const { locale, sourceType, sourceUrl, cubeFilters } = variables; if (cubeFilters.length > 1 && !cubeFilters.every((f) => f.joinBy)) { + console.log({ cubeFilters }); throw new Error( "When fetching data from multiple cubes, all cube filters must have joinBy property set." ); @@ -262,7 +278,19 @@ export const executeDataCubesComponentsQuery = async ( export const useDataCubesComponentsQuery = makeUseQuery< DataCubesComponentsOptions, DataCubesComponentsData ->(executeDataCubesComponentsQuery); +>({ + check: (variables: DataCubesComponentsOptions["variables"]) => { + const { cubeFilters } = variables; + if (cubeFilters.length > 1 && !cubeFilters.every((f) => f.joinBy)) { + console.log({ cubeFilters }); + throw new Error( + "When fetching data from multiple cubes, all cube filters must have joinBy property set." + ); + } + }, + + fetch: executeDataCubesComponentsQuery, +}); type DataCubesObservationsOptions = { variables: Omit & { @@ -283,12 +311,6 @@ export const executeDataCubesObservationsQuery = async ( ) => { const { locale, sourceType, sourceUrl, cubeFilters } = variables; - if (cubeFilters.length > 1 && !cubeFilters.every((f) => f.joinBy)) { - throw new Error( - "When fetching data from multiple cubes, all cube filters must have joinBy property set." - ); - } - const queries = await Promise.all( cubeFilters.map((cubeFilter) => { const cubeVariables = { locale, sourceType, sourceUrl, cubeFilter }; @@ -348,4 +370,79 @@ export const executeDataCubesObservationsQuery = async ( export const useDataCubesObservationsQuery = makeUseQuery< DataCubesObservationsOptions, DataCubesObservationsData ->(executeDataCubesObservationsQuery); +>({ + check: (variables) => { + const { cubeFilters } = variables; + + if (cubeFilters.length > 1 && !cubeFilters.every((f) => f.joinBy)) { + throw new Error( + "When fetching data from multiple cubes, all cube filters must have joinBy property set." + ); + } + }, + fetch: executeDataCubesObservationsQuery, +}); + +type FetchAllUsedCubeComponentsOptions = { + state: ConfiguratorState; + locale: Locale; +}; +/** + * Fetches all cubes components in one go. Is useful in contexts where we deal + * with all the cubes at once, for example the shared dashboard filters. + */ +export const executeFetchAllUsedCubeComponents = async ( + client: Client, + variables: FetchAllUsedCubeComponentsOptions +) => { + const { state, locale } = variables; + const { dataSource } = state; + assert(hasChartConfigs(state), "Expected state with chart configs"); + + const cubeFilters: DataCubeComponentFilter[][] = state.chartConfigs.map( + (config) => { + return config.cubes.map((x) => ({ + iri: x.iri, + joinBy: x.joinBy, + loadValues: true, + })); + } + ); + + // executeDataCubesComponentsQuery dedupes queries through urql cache + const dataCubesComponents = await Promise.all( + cubeFilters.map((cf) => + executeDataCubesComponentsQuery(client, { + cubeFilters: cf, + locale, + sourceType: dataSource.type, + sourceUrl: dataSource.url, + }) + ) + ); + + return { + error: dataCubesComponents.find((x) => x.error)?.error, + fetching: dataCubesComponents.some((x) => x.fetching), + data: { + dataCubesComponents: { + dimensions: dataCubesComponents.flatMap( + (x) => x?.data?.dataCubesComponents.dimensions ?? [] + ), + measures: dataCubesComponents.flatMap( + (x) => x?.data?.dataCubesComponents.measures ?? [] + ), + }, + }, + }; +}; + +export const useConfigsCubeComponents = makeUseQuery< + { + variables: FetchAllUsedCubeComponentsOptions; + pause?: boolean | undefined; + }, + Awaited>["data"] +>({ + fetch: executeFetchAllUsedCubeComponents, +}); diff --git a/app/homepage/examples.tsx b/app/homepage/examples.tsx index 7990fcb0b..f4be0f4c5 100644 --- a/app/homepage/examples.tsx +++ b/app/homepage/examples.tsx @@ -70,11 +70,14 @@ type ExampleProps = { const Example = (props: ExampleProps) => { const { queryKey, configuratorState, headline, description, reverse } = props; const client = useClient(); - const { data, error } = useFetchData([queryKey], () => { - return upgradeConfiguratorState(configuratorState, { - client, - dataSource: configuratorState.dataSource, - }); + const { data, error } = useFetchData({ + queryKey: [queryKey], + queryFn: () => { + return upgradeConfiguratorState(configuratorState, { + client, + dataSource: configuratorState.dataSource, + }); + }, }); return data ? ( diff --git a/app/utils/promises.ts b/app/utils/promises.ts new file mode 100644 index 000000000..04590941c --- /dev/null +++ b/app/utils/promises.ts @@ -0,0 +1,23 @@ +type PromiserFunction = (item: T) => Promise; + +export async function allDeduped(params: { + items: T[]; + promiser: PromiserFunction; + key: (item: T) => string; +}): Promise { + const { items, promiser, key } = params; + const promises: Record> = {}; + const results = await Promise.all( + items.map((item) => { + const k = key(item); + if (!promises[k]) { + const promise = promiser(item); + promises[k] = promise; + return promise; + } else { + return promises[k]; + } + }) + ); + return results; +} diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index 0800d3c07..da62de66b 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -82,11 +82,15 @@ const useCacheKey = (cache: QueryCache, queryKey: QueryKey) => { * Two useFetchData on the same queryKey will result in only 1 queryFn called. Both useFetchData * will share the same cache and data. */ -export const useFetchData = ( - queryKey: any[], - queryFn: () => Promise, - options: Partial> = {} -) => { +export const useFetchData = ({ + queryKey, + queryFn, + options = {}, +}: { + queryKey: any[]; + queryFn: () => Promise; + options?: Partial>; +}) => { const { enable = true, defaultData } = options; const cached = cache.get(queryKey) as QueryCacheValue;