diff --git a/app/browse/datatable.tsx b/app/browse/datatable.tsx index b4df070ea..8dc0f4a4d 100644 --- a/app/browse/datatable.tsx +++ b/app/browse/datatable.tsx @@ -20,7 +20,11 @@ import { } from "@/charts/shared/chart-helpers"; import { Loading } from "@/components/hint"; import { OpenMetadataPanelWrapper } from "@/components/metadata-panel"; -import { ChartConfig, DataSource } from "@/config-types"; +import { + ChartConfig, + DashboardFiltersConfig, + DataSource, +} from "@/config-types"; import { Component, Dimension, @@ -229,10 +233,12 @@ export const DataSetPreviewTable = ({ export const DataSetTable = ({ dataSource, chartConfig, + dashboardFilters, sx, }: { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; sx?: SxProps; }) => { const locale = useLocale(); @@ -269,7 +275,11 @@ export const DataSetTable = ({ ...componentsData.dataCubesComponents.measures, ]); }, [componentsData?.dataCubesComponents]); - const queryFilters = useQueryFilters({ chartConfig, componentIris }); + const queryFilters = useQueryFilters({ + chartConfig, + dashboardFilters, + componentIris, + }); const [{ data: observationsData }] = useDataCubesObservationsQuery({ variables: { ...commonQueryVariables, diff --git a/app/charts/column/chart-column.tsx b/app/charts/column/chart-column.tsx index eef1ae59e..5e8528a9f 100644 --- a/app/charts/column/chart-column.tsx +++ b/app/charts/column/chart-column.tsx @@ -25,8 +25,9 @@ import { import { Tooltip } from "@/charts/shared/interaction/tooltip"; import { LegendColor } from "@/charts/shared/legend-color"; import { ColumnConfig, useChartConfigFilters } from "@/config-types"; +import { hasChartConfigs } from "@/configurator"; import { TimeSlider } from "@/configurator/interactive-filters/time-slider"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { useConfiguratorState } from "@/src"; import { ChartProps, VisualizationProps } from "../shared/ChartProps"; @@ -40,10 +41,10 @@ const ChartColumns = memo((props: ChartProps) => { const { chartConfig, dimensions } = props; const { fields, interactiveFiltersConfig } = chartConfig; const filters = useChartConfigFilters(chartConfig); - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); const showTimeBrush = shouldShowBrush( interactiveFiltersConfig, - dashboardFilters.timeRange + dashboardFilters?.timeRange ); return ( <> diff --git a/app/charts/combo/chart-combo-line-column.tsx b/app/charts/combo/chart-combo-line-column.tsx index bd701e012..f6687b931 100644 --- a/app/charts/combo/chart-combo-line-column.tsx +++ b/app/charts/combo/chart-combo-line-column.tsx @@ -12,7 +12,8 @@ import { HoverDotMultiple } from "@/charts/shared/interaction/hover-dots-multipl import { Ruler } from "@/charts/shared/interaction/ruler"; import { Tooltip } from "@/charts/shared/interaction/tooltip"; import { ComboLineColumnConfig } from "@/config-types"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { hasChartConfigs } from "@/configurator"; +import { useConfiguratorState } from "@/src"; import { ChartProps, VisualizationProps } from "../shared/ChartProps"; @@ -26,7 +27,7 @@ const ChartComboLineColumn = memo( (props: ChartProps) => { const { chartConfig } = props; const { interactiveFiltersConfig } = chartConfig; - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return ( @@ -38,7 +39,7 @@ const ChartComboLineColumn = memo( {shouldShowBrush( interactiveFiltersConfig, - dashboardFilters.timeRange + dashboardFilters?.timeRange ) && } diff --git a/app/charts/combo/chart-combo-line-dual.tsx b/app/charts/combo/chart-combo-line-dual.tsx index 1203f2fb2..a1c831c07 100644 --- a/app/charts/combo/chart-combo-line-dual.tsx +++ b/app/charts/combo/chart-combo-line-dual.tsx @@ -12,7 +12,8 @@ import { Ruler } from "@/charts/shared/interaction/ruler"; import { Tooltip } from "@/charts/shared/interaction/tooltip"; import { InteractionHorizontal } from "@/charts/shared/overlay-horizontal"; import { ComboLineDualConfig } from "@/config-types"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { hasChartConfigs } from "@/configurator"; +import { useConfiguratorState } from "@/src"; import { ChartProps, VisualizationProps } from "../shared/ChartProps"; @@ -25,7 +26,7 @@ export const ChartComboLineDualVisualization = ( const ChartComboLineDual = memo((props: ChartProps) => { const { chartConfig } = props; const { interactiveFiltersConfig } = chartConfig; - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return ( @@ -37,7 +38,7 @@ const ChartComboLineDual = memo((props: ChartProps) => { {shouldShowBrush( interactiveFiltersConfig, - dashboardFilters.timeRange + dashboardFilters?.timeRange ) && } diff --git a/app/charts/combo/chart-combo-line-single.tsx b/app/charts/combo/chart-combo-line-single.tsx index 19c53722e..bd50ffdc7 100644 --- a/app/charts/combo/chart-combo-line-single.tsx +++ b/app/charts/combo/chart-combo-line-single.tsx @@ -12,7 +12,8 @@ import { Ruler } from "@/charts/shared/interaction/ruler"; import { Tooltip } from "@/charts/shared/interaction/tooltip"; import { InteractionHorizontal } from "@/charts/shared/overlay-horizontal"; import { ComboLineSingleConfig } from "@/config-types"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { hasChartConfigs } from "@/configurator"; +import { useConfiguratorState } from "@/src"; import { ChartProps, VisualizationProps } from "../shared/ChartProps"; @@ -26,7 +27,7 @@ const ChartComboLineSingle = memo( (props: ChartProps) => { const { chartConfig } = props; const { interactiveFiltersConfig } = chartConfig; - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return ( @@ -36,7 +37,7 @@ const ChartComboLineSingle = memo( {shouldShowBrush( interactiveFiltersConfig, - dashboardFilters.timeRange + dashboardFilters?.timeRange ) && } diff --git a/app/charts/line/chart-lines.tsx b/app/charts/line/chart-lines.tsx index 308b1a484..510f8418d 100644 --- a/app/charts/line/chart-lines.tsx +++ b/app/charts/line/chart-lines.tsx @@ -17,7 +17,7 @@ import { Tooltip } from "@/charts/shared/interaction/tooltip"; import { LegendColor } from "@/charts/shared/legend-color"; import { InteractionHorizontal } from "@/charts/shared/overlay-horizontal"; import { LineConfig } from "@/config-types"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { hasChartConfigs, useConfiguratorState } from "@/configurator"; import { ChartProps, VisualizationProps } from "../shared/ChartProps"; @@ -30,7 +30,7 @@ export const ChartLinesVisualization = ( const ChartLines = memo((props: ChartProps) => { const { chartConfig } = props; const { fields, interactiveFiltersConfig } = chartConfig; - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return ( @@ -41,7 +41,7 @@ const ChartLines = memo((props: ChartProps) => { {shouldShowBrush( interactiveFiltersConfig, - dashboardFilters.timeRange + dashboardFilters?.timeRange ) && } diff --git a/app/charts/shared/chart-data-filters.tsx b/app/charts/shared/chart-data-filters.tsx index 2412a98ac..cdb8d91b1 100644 --- a/app/charts/shared/chart-data-filters.tsx +++ b/app/charts/shared/chart-data-filters.tsx @@ -17,7 +17,9 @@ import { Loading } from "@/components/hint"; import { OpenMetadataPanelWrapper } from "@/components/metadata-panel"; import SelectTree from "@/components/select-tree"; import { + areDataFiltersActive, ChartConfig, + DashboardFiltersConfig, DataSource, Filters, getFiltersByMappingStatus, @@ -66,9 +68,11 @@ type PreparedFilter = { export const useChartDataFiltersState = ({ dataSource, chartConfig, + dashboardFilters, }: { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; }) => { const componentIris = chartConfig.interactiveFiltersConfig?.dataFilters.componentIris; @@ -81,6 +85,7 @@ export const useChartDataFiltersState = ({ const { loading } = useLoadingState(); const queryFilters = useQueryFilters({ chartConfig, + dashboardFilters, allowNoneValues: true, componentIris, }); @@ -107,10 +112,12 @@ export const useChartDataFiltersState = ({ }; }); }, [chartConfig, componentIris, queryFilters]); + // TODO: disable when dashboard filters are active? const { error } = useEnsurePossibleInteractiveFilters({ dataSource, chartConfig, preparedFilters, + dashboardFilters, }); return { open, @@ -371,7 +378,9 @@ export type DataFilterGenericDimensionProps = { disabled: boolean; }; -const DataFilterGenericDimension = (props: DataFilterGenericDimensionProps) => { +export const DataFilterGenericDimension = ( + props: DataFilterGenericDimensionProps +) => { const { dimension, value, onChange, options: propOptions, disabled } = props; const { label, isKeyDimension } = dimension; const noneLabel = t({ @@ -420,7 +429,7 @@ type DataFilterHierarchyDimensionProps = { disabled: boolean; }; -const DataFilterHierarchyDimension = ( +export const DataFilterHierarchyDimension = ( props: DataFilterHierarchyDimensionProps ) => { const { dimension, value, onChange, hierarchy, disabled } = props; @@ -475,7 +484,7 @@ const DataFilterHierarchyDimension = ( ); }; -const DataFilterTemporalDimension = ({ +export const DataFilterTemporalDimension = ({ dimension, value, onChange, @@ -532,20 +541,19 @@ const DataFilterTemporalDimension = ({ ); }; -type EnsurePossibleInteractiveFiltersProps = { - dataSource: DataSource; - chartConfig: ChartConfig; - preparedFilters?: PreparedFilter[]; -}; - /** * This runs every time the state changes and it ensures that the selected interactive * filters return at least 1 observation. Otherwise they are reloaded. + * + * This behavior is disabled when the dashboard filters are active. */ -const useEnsurePossibleInteractiveFilters = ( - props: EnsurePossibleInteractiveFiltersProps -) => { - const { dataSource, chartConfig, preparedFilters } = props; +const useEnsurePossibleInteractiveFilters = (props: { + dataSource: DataSource; + chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; + preparedFilters?: PreparedFilter[]; +}) => { + const { dataSource, chartConfig, dashboardFilters, preparedFilters } = props; const [, dispatch] = useConfiguratorState(); const loadingState = useLoadingState(); const [error, setError] = useState(); @@ -560,9 +568,11 @@ const useEnsurePossibleInteractiveFilters = ( }, {}); }, [preparedFilters]); + const dataFiltersActive = areDataFiltersActive(dashboardFilters); + useEffect(() => { const run = async () => { - if (!filtersByCubeIri) { + if (!filtersByCubeIri || dataFiltersActive) { return; } @@ -660,6 +670,7 @@ const useEnsurePossibleInteractiveFilters = ( loadingState, filtersByCubeIri, getInteractiveFiltersState, + dataFiltersActive, ]); return { error }; diff --git a/app/charts/shared/chart-dimensions.tsx b/app/charts/shared/chart-dimensions.tsx index 206c1653b..93f93e05a 100644 --- a/app/charts/shared/chart-dimensions.tsx +++ b/app/charts/shared/chart-dimensions.tsx @@ -9,11 +9,11 @@ import { Bounds, Margins } from "@/charts/shared/use-size"; import { CHART_GRID_MIN_HEIGHT } from "@/components/react-grid"; import { ChartConfig, + DashboardFiltersConfig, hasChartConfigs, isLayoutingFreeCanvas, useConfiguratorState, } from "@/configurator"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; import { getTextWidth } from "@/utils/get-text-width"; type ComputeChartPaddingProps = { @@ -29,7 +29,7 @@ type ComputeChartPaddingProps = { const computeChartPadding = ( props: ComputeChartPaddingProps & { - dashboardFilters: ReturnType; + dashboardFilters: DashboardFiltersConfig | undefined; } ) => { const { @@ -61,7 +61,7 @@ const computeChartPadding = ( ); let bottom = - (!dashboardFilters.timeRange?.active && + (!dashboardFilters?.timeRange.active && !!interactiveFiltersConfig?.timeRange.active) || animationPresent ? BRUSH_BOTTOM_SPACE @@ -87,7 +87,7 @@ export const useChartPadding = (props: ComputeChartPaddingProps) => { bandDomain, normalize, } = props; - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); return useMemo(() => { return computeChartPadding({ yScale, diff --git a/app/charts/shared/chart-helpers.spec.tsx b/app/charts/shared/chart-helpers.spec.tsx index 67c8ce39c..d56df31ad 100644 --- a/app/charts/shared/chart-helpers.spec.tsx +++ b/app/charts/shared/chart-helpers.spec.tsx @@ -14,7 +14,10 @@ import { } from "@/configurator"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; import { mkJoinById } from "@/graphql/join"; -import { InteractiveFiltersState } from "@/stores/interactive-filters"; +import { + InteractiveFiltersState, + useChartInteractiveFilters, +} from "@/stores/interactive-filters"; import dualLine1Fixture from "@/test/__fixtures/config/dev/chartConfig-photovoltaik-und-gebaudeprogramm.json"; import map1Fixture from "@/test/__fixtures/config/int/map-nfi.json"; import line1Fixture from "@/test/__fixtures/config/prod/line-1.json"; @@ -93,6 +96,7 @@ describe("useQueryFilters", () => { line1Fixture.data.chartConfig.chartType as ChartType, line1Fixture.data.chartConfig.filters as Filters, commonInteractiveFiltersConfig, + undefined, commonInteractiveFiltersState.dataFilters ); expect(queryFilters[col("3")]).toEqual({ @@ -110,6 +114,7 @@ describe("useQueryFilters", () => { active: true, }, }), + undefined, commonInteractiveFiltersState.dataFilters ); @@ -128,6 +133,7 @@ describe("useQueryFilters", () => { active: true, }, }), + undefined, merge({}, commonInteractiveFiltersState, { dataFilters: { [col("3")]: { @@ -171,7 +177,7 @@ describe("useQueryFilters", () => { >( (props: Parameters[0]) => useQueryFilters(props), { - initialProps: { chartConfig }, + initialProps: { chartConfig, dashboardFilters: undefined }, } ); @@ -193,6 +199,80 @@ describe("useQueryFilters", () => { }, ]); }); + + it("should handle correctly dashboard filters", () => { + (useChartInteractiveFilters as jest.Mock).mockImplementation(() => ({})); + + const chartConfig = { + chartType: "line", + interactiveFiltersConfig: { + dataFilters: { + active: true, + }, + }, + cubes: [ + { + iri: "A", + filters: { + A_1: { type: "single", value: "A_1_5" }, + A_2: { type: "single", value: "A_2_1" }, + }, + }, + { + iri: "B", + filters: { + B_1: { type: "single", value: "B_1_1" }, + }, + }, + ], + } as any as ChartConfig; + const { result: queryFilters } = renderHook< + ReturnType, + Parameters[0] + >( + (props: Parameters[0]) => useQueryFilters(props), + { + initialProps: { + chartConfig, + dashboardFilters: { + timeRange: { + active: false, + timeUnit: "ms", + presets: { + from: "2021-01-01", + to: "2021-12-31", + }, + }, + dataFilters: { + componentIris: ["A_1"], + filters: { + A_1: { type: "single", value: "A_1_Data_Filter" }, + A_2: { type: "single", value: "A_2_3" }, + }, + }, + }, + }, + } + ); + + expect(queryFilters.current).toEqual([ + { + iri: "A", + componentIris: undefined, + filters: { + A_1: { type: "single", value: "A_1_Data_Filter" }, + A_2: { type: "single", value: "A_2_1" }, + }, + joinBy: undefined, + }, + { + iri: "B", + componentIris: undefined, + filters: { B_1: { type: "single", value: "B_1_1" } }, + joinBy: undefined, + }, + ]); + }); }); describe("getChartConfigComponentIris", () => { diff --git a/app/charts/shared/chart-helpers.tsx b/app/charts/shared/chart-helpers.tsx index b214422f9..7616b9680 100644 --- a/app/charts/shared/chart-helpers.tsx +++ b/app/charts/shared/chart-helpers.tsx @@ -23,10 +23,11 @@ import { import { CategoricalColorField, ComboChartConfig, + DashboardFiltersConfig, GenericField, - NumericalColorField, getChartConfigFilters, isComboChartConfig, + NumericalColorField, } from "@/configurator"; import { parseDate } from "@/configurator/components/ui-helpers"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; @@ -34,9 +35,9 @@ import { Component, Dimension, DimensionValue, + getTemporalEntityValue, Observation, ObservationValue, - getTemporalEntityValue, } from "@/domain/data"; import { truthy } from "@/domain/types"; import { getOriginalIris, isJoinById } from "@/graphql/join"; @@ -47,21 +48,37 @@ import { } from "@/stores/interactive-filters"; // Prepare filters used in data query: -// - merges publisher data filters and interactive data filters (user-defined), +// - merges publisher data filters, interactive data filters, and dashboard filters // if applicable // - removes none values since they should not be sent as part of the GraphQL query export const prepareCubeQueryFilters = ( chartType: ChartType, cubeFilters: Filters, interactiveFiltersConfig: InteractiveFiltersConfig, - cubeDataFilters: InteractiveFiltersState["dataFilters"], + dashboardFiltersConfig: DashboardFiltersConfig | undefined, + interactiveDataFilters: InteractiveFiltersState["dataFilters"], allowNoneValues = false ): Filters => { const queryFilters = { ...cubeFilters }; - if (chartType !== "table" && interactiveFiltersConfig?.dataFilters.active) { - for (const [k, v] of Object.entries(cubeDataFilters)) { - queryFilters[k] = v; + if (chartType !== "table") { + for (const [k, v] of Object.entries( + dashboardFiltersConfig?.dataFilters.filters ?? {} + )) { + if ( + k in cubeFilters && + dashboardFiltersConfig?.dataFilters.componentIris?.includes(k) + ) { + queryFilters[k] = v; + } + } + for (const [k, v] of Object.entries(interactiveDataFilters)) { + if ( + interactiveFiltersConfig?.dataFilters.active || + dashboardFiltersConfig?.dataFilters.componentIris?.includes(k) + ) { + queryFilters[k] = v; + } } } @@ -75,16 +92,19 @@ export const prepareCubeQueryFilters = ( export const useQueryFilters = ({ chartConfig, + dashboardFilters, allowNoneValues, componentIris, }: { chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; allowNoneValues?: boolean; componentIris?: string[]; }): DataCubeObservationFilter[] => { - const allInteractiveDataFilters = useChartInteractiveFilters( + const chartInteractiveFilters = useChartInteractiveFilters( (d) => d.dataFilters ); + return useMemo(() => { return chartConfig.cubes.map((cube) => { const cubeFilters = getChartConfigFilters(chartConfig.cubes, { @@ -95,22 +115,25 @@ export const useQueryFilters = ({ // This is a bigger issue we should address in the future, probably by keeping // track of interactive data filters per cube. // Only include data filters that are part of the chart config. - const cubeDataInteractiveFilters = Object.fromEntries( - Object.entries(allInteractiveDataFilters).filter(([key]) => + const cubeInteractiveDataFilters = Object.fromEntries( + Object.entries(chartInteractiveFilters).filter(([key]) => cubeFiltersKeys.includes(key) ) ); + const preparedFilters = prepareCubeQueryFilters( + chartConfig.chartType, + cubeFilters, + chartConfig.interactiveFiltersConfig, + dashboardFilters, + cubeInteractiveDataFilters, + allowNoneValues + ); + return { iri: cube.iri, componentIris, - filters: prepareCubeQueryFilters( - chartConfig.chartType, - cubeFilters, - chartConfig.interactiveFiltersConfig, - cubeDataInteractiveFilters, - allowNoneValues - ), + filters: preparedFilters, joinBy: cube.joinBy, }; }); @@ -118,9 +141,10 @@ export const useQueryFilters = ({ chartConfig.cubes, chartConfig.chartType, chartConfig.interactiveFiltersConfig, - allInteractiveDataFilters, - allowNoneValues, + chartInteractiveFilters, componentIris, + dashboardFilters, + allowNoneValues, ]); }; diff --git a/app/charts/shared/chart-state.ts b/app/charts/shared/chart-state.ts index 66fb03403..3caf00321 100644 --- a/app/charts/shared/chart-state.ts +++ b/app/charts/shared/chart-state.ts @@ -32,6 +32,8 @@ import { GenericField, InteractiveFiltersConfig, getAnimationField, + hasChartConfigs, + useConfiguratorState, } from "@/configurator"; import { parseDate, @@ -57,10 +59,7 @@ import { } from "@/domain/data"; import { Has } from "@/domain/types"; import { ScaleType, TimeUnit } from "@/graphql/resolver-types"; -import { - useChartInteractiveFilters, - useDashboardInteractiveFilters, -} from "@/stores/interactive-filters"; +import { useChartInteractiveFilters } from "@/stores/interactive-filters"; export type ChartState = | AreasState @@ -508,13 +507,13 @@ export const useChartData = ( // interactive time range const interactiveFromTime = timeRange.from?.getTime(); const interactiveToTime = timeRange.to?.getTime(); - const dashboardFilters = useDashboardInteractiveFilters(); + const [{ dashboardFilters }] = useConfiguratorState(hasChartConfigs); const interactiveTimeRangeFilters = useMemo(() => { const interactiveTimeRangeFilter: ValuePredicate | null = getXAsDate && interactiveFromTime && interactiveToTime && - (interactiveTimeRange?.active || dashboardFilters.timeRange?.active) + (interactiveTimeRange?.active || dashboardFilters?.timeRange.active) ? (d: Observation) => { const time = getXAsDate(d).getTime(); return time >= interactiveFromTime && time <= interactiveToTime; @@ -523,7 +522,7 @@ export const useChartData = ( return interactiveTimeRangeFilter ? [interactiveTimeRangeFilter] : []; }, [ - dashboardFilters.timeRange, + dashboardFilters?.timeRange, getXAsDate, interactiveFromTime, interactiveToTime, diff --git a/app/charts/shared/use-sync-interactive-filters.spec.tsx b/app/charts/shared/use-sync-interactive-filters.spec.tsx index 8b1e917ca..f9b88fb81 100644 --- a/app/charts/shared/use-sync-interactive-filters.spec.tsx +++ b/app/charts/shared/use-sync-interactive-filters.spec.tsx @@ -101,7 +101,10 @@ const setup = ({ calculation: d.calculation, })); const [useModified, setUseModified] = useState(false); - useSyncInteractiveFilters(useModified ? modifiedChartConfig : chartConfig); + useSyncInteractiveFilters( + useModified ? modifiedChartConfig : chartConfig, + undefined + ); return (
diff --git a/app/charts/shared/use-sync-interactive-filters.tsx b/app/charts/shared/use-sync-interactive-filters.tsx index d4c5c37c7..eb25d880f 100644 --- a/app/charts/shared/use-sync-interactive-filters.tsx +++ b/app/charts/shared/use-sync-interactive-filters.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react"; import { ChartConfig, + DashboardFiltersConfig, FilterValueSingle, isSegmentInConfig, useChartConfigFilters, @@ -9,6 +10,7 @@ import { import { parseDate } from "@/configurator/components/ui-helpers"; import { FIELD_VALUE_NONE } from "@/configurator/constants"; import useFilterChanges from "@/configurator/use-filter-changes"; +import { truthy } from "@/domain/types"; import { useChartInteractiveFilters } from "@/stores/interactive-filters"; /** @@ -18,7 +20,10 @@ import { useChartInteractiveFilters } from "@/stores/interactive-filters"; * inside the interactive filters * */ -const useSyncInteractiveFilters = (chartConfig: ChartConfig) => { +const useSyncInteractiveFilters = ( + chartConfig: ChartConfig, + dashboardFilters: DashboardFiltersConfig | undefined +) => { const { interactiveFiltersConfig } = chartConfig; const filters = useChartConfigFilters(chartConfig); const resetCategories = useChartInteractiveFilters((d) => d.resetCategories); @@ -52,28 +57,58 @@ const useSyncInteractiveFilters = (chartConfig: ChartConfig) => { // Data Filters const componentIris = interactiveFiltersConfig?.dataFilters.componentIris; - useEffect(() => { + const dashboardComponentIris = dashboardFilters?.dataFilters.componentIris; + const newPotentialInteractiveDataFilters = useMemo(() => { if (componentIris) { // If dimension is already in use as interactive filter, use it, - // otherwise, default to editor config filter dimension value. - const newInteractiveDataFilters = componentIris.reduce<{ - [key: string]: FilterValueSingle; - }>((obj, iri) => { - const configFilter = filters[iri]; + // otherwise, default to editor config filter dimension value (only + // if dashboard filters are not set). + return componentIris.concat(dashboardComponentIris ?? []); + } + }, [componentIris, dashboardComponentIris]); - if (Object.keys(dataFilters).includes(iri)) { - obj[iri] = dataFilters[iri]; - } else if (configFilter?.type === "single") { - obj[iri] = configFilter; - } + useEffect(() => { + if (newPotentialInteractiveDataFilters) { + const newInteractiveDataFilters = Object.fromEntries( + Object.entries(newPotentialInteractiveDataFilters) + .map(([iri]) => { + const dashboardFilter = dashboardFilters?.dataFilters.filters[iri]; + return dashboardFilter?.type === "single" + ? ([iri, dashboardFilter] as const) + : null; + }) + .filter(truthy) + ); - return obj; - }, {}); + setDataFilters(newInteractiveDataFilters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + newPotentialInteractiveDataFilters, + dashboardComponentIris, + dashboardFilters?.dataFilters.filters, + ]); + + useEffect(() => { + if (newPotentialInteractiveDataFilters) { + const newInteractiveDataFilters = + newPotentialInteractiveDataFilters.reduce( + (obj, iri) => { + const configFilter = filters[iri]; + if (Object.keys(dataFilters).includes(iri)) { + obj[iri] = dataFilters[iri]; + } else if (configFilter?.type === "single") { + obj[iri] = configFilter; + } + return obj; + }, + {} as { [key: string]: FilterValueSingle } + ); setDataFilters(newInteractiveDataFilters); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [componentIris, setDataFilters]); + }, [newPotentialInteractiveDataFilters, setDataFilters]); const changes = useFilterChanges(filters); useEffect(() => { diff --git a/app/components/chart-filters-list.tsx b/app/components/chart-filters-list.tsx index 85075282c..e8f5edeb6 100644 --- a/app/components/chart-filters-list.tsx +++ b/app/components/chart-filters-list.tsx @@ -9,6 +9,7 @@ import { import { OpenMetadataPanelWrapper } from "@/components/metadata-panel"; import { ChartConfig, + DashboardFiltersConfig, DataSource, FilterValue, getAnimationField, @@ -28,11 +29,13 @@ export const ChartFiltersList = ({ cubeIri, dataSource, chartConfig, + dashboardFilters, components, }: { cubeIri: string; dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; components: Component[]; }) => { const locale = useLocale(); @@ -41,6 +44,7 @@ export const ChartFiltersList = ({ const animationField = getAnimationField(chartConfig); const queryFilters = useQueryFilters({ chartConfig, + dashboardFilters, componentIris: extractChartConfigComponentIris({ chartConfig }), }); const cubeQueryFilters = useMemo(() => { diff --git a/app/components/chart-footnotes.tsx b/app/components/chart-footnotes.tsx index 449a50672..d744f37dc 100644 --- a/app/components/chart-footnotes.tsx +++ b/app/components/chart-footnotes.tsx @@ -12,6 +12,7 @@ import { ComboLineColumnConfig, ComboLineDualConfig, ComboLineSingleConfig, + DashboardFiltersConfig, DataSource, } from "@/configurator"; import { Component, Measure } from "@/domain/data"; @@ -43,11 +44,13 @@ export const useFootnotesStyles = makeStyles( export const ChartFootnotes = ({ dataSource, chartConfig, + dashboardFilters, components, showVisualizeLink = false, }: { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; components: Component[]; showVisualizeLink?: boolean; }) => { @@ -89,6 +92,7 @@ export const ChartFootnotes = ({ diff --git a/app/components/chart-preview.tsx b/app/components/chart-preview.tsx index e72642f68..aad545527 100644 --- a/app/components/chart-preview.tsx +++ b/app/components/chart-preview.tsx @@ -11,8 +11,8 @@ import { Box } from "@mui/material"; import { makeStyles } from "@mui/styles"; import Head from "next/head"; import React, { - ReactNode, forwardRef, + ReactNode, useCallback, useMemo, useState, @@ -45,17 +45,17 @@ import Flex from "@/components/flex"; import { Checkbox } from "@/components/form"; import { HintYellow } from "@/components/hint"; import { - MetadataPanelStoreContext, createMetadataPanelStore, + MetadataPanelStoreContext, } from "@/components/metadata-panel-store"; import { BANNER_MARGIN_TOP } from "@/components/presence"; import { ChartConfig, DataSource, - Layout, getChartConfig, hasChartConfigs, isConfiguring, + Layout, useConfiguratorState, } from "@/configurator"; import { Description, Title } from "@/configurator/components/annotators"; @@ -79,31 +79,39 @@ export const ChartPreview = (props: ChartPreviewProps) => { const [state] = useConfiguratorState(hasChartConfigs); const editing = isConfiguring(state); const { layout } = state; + const metadataPanelStore = useMemo(() => { + return createMetadataPanelStore(); + }, []); - return layout.type === "dashboard" && !editing ? ( - - ) : layout.type === "singleURLs" && !editing ? ( - - ) : ( - // Important to keep the key here to force re-rendering of the chart when - // we switch tabs in the configurator, otherwise we end up with the wrong - // data in the downstream hooks (useDataCubesMetadataQuery, etc.) - <> - {state.state !== "CONFIGURING_CHART" ? ( - x.key).join(",")} - /> - ) : null} - - - - - - + return ( + + {layout.type === "dashboard" && !editing ? ( + + ) : layout.type === "singleURLs" && !editing ? ( + + ) : ( + // Important to keep the key here to force re-rendering of the chart when + // we switch tabs in the configurator, otherwise we end up with the wrong + // data in the downstream hooks (useDataCubesMetadataQuery, etc.) + <> + {!isConfiguring(state) ? ( + x.key).join(",")} + sx={{ mb: 4 }} + /> + ) : null} + + + + + + + )} + ); }; @@ -424,86 +432,47 @@ const ChartPreviewInner = (props: ChartPreviewInnerProps) => { return [...dimensions, ...measures]; }, [dimensions, measures]); - const metadataPanelStore = useMemo(() => { - return createMetadataPanelStore(); - }, []); return ( - - - {props.children} - - {hasChartConfigs(state) && ( - <> - - - {!chartConfig.meta.title[locale] - ? // FIXME: adapt to design - metadata?.dataCubesMetadata.map((d) => d.title).join(", ") - : chartConfig.meta.title[locale]}{" "} - - visualize.admin.ch - - - - + {props.children} + + {hasChartConfigs(state) && ( + <> + + + {!chartConfig.meta.title[locale] + ? // FIXME: adapt to design + metadata?.dataCubesMetadata.map((d) => d.title).join(", ") + : chartConfig.meta.title[locale]}{" "} + - visualize.admin.ch + + + + + - - {configuring || chartConfig.meta.title[locale] ? ( - - dispatch({ - type: "CHART_ACTIVE_FIELD_CHANGED", - value: "title", - }) - : undefined - } - /> - ) : ( - // We need to have a span here to keep the space between the - // title and the chart (subgrid layout) - <span style={{ height: 1 }} /> - )} - <Box - sx={{ - display: "flex", - alignItems: "center", - gap: 2, - mt: "-0.33rem", - }} - > - <ChartMoreButton chartKey={chartConfig.key} /> - {actionElementSlot} - </Box> - </Flex> - {configuring || chartConfig.meta.description[locale] ? ( - <Description - text={chartConfig.meta.description[locale]} + {configuring || chartConfig.meta.title[locale] ? ( + <Title + text={chartConfig.meta.title[locale]} lighterColor smaller={state.layout.type === "dashboard"} onClick={ configuring - ? () => { + ? () => dispatch({ type: "CHART_ACTIVE_FIELD_CHANGED", - value: "description", - }); - } + value: "title", + }) : undefined } /> @@ -512,68 +481,104 @@ const ChartPreviewInner = (props: ChartPreviewInnerProps) => { // title and the chart (subgrid layout) <span style={{ height: 1 }} /> )} - <Box sx={{ mt: 4 }}> - {metadata?.dataCubesMetadata.some( - (d) => - d.publicationStatus === DataCubePublicationStatus.Draft - ) && ( - <Box sx={{ mb: 4 }}> - <HintYellow> - <Trans id="dataset.publicationStatus.draft.warning"> - Careful, this dataset is only a draft. - <br /> - <strong>Don't use for reporting!</strong> - </Trans> - </HintYellow> - </Box> - )} - </Box> - <ChartControls - dataSource={dataSource} - chartConfig={chartConfig} - metadataPanelProps={{ - components: allComponents, - top: BANNER_MARGIN_TOP, - }} - /> - <div - ref={containerRef} - style={{ - minWidth: 0, - height: containerHeight, - paddingTop: 16, - flexGrow: 1, + <Box + sx={{ + display: "flex", + alignItems: "center", + gap: 2, + mt: "-0.33rem", }} > - {isTable ? ( - <DataSetTable - dataSource={dataSource} - chartConfig={chartConfig} - sx={{ width: "100%", maxHeight: "100%" }} - /> - ) : ( - <ChartWithFilters - dataSource={dataSource} - componentIris={componentIris} - chartConfig={chartConfig} - /> - )} - </div> - <ChartFootnotes - dataSource={dataSource} - chartConfig={chartConfig} - components={allComponents} + <ChartMoreButton chartKey={chartConfig.key} /> + {actionElementSlot} + </Box> + </Flex> + {configuring || chartConfig.meta.description[locale] ? ( + <Description + text={chartConfig.meta.description[locale]} + lighterColor + smaller={state.layout.type === "dashboard"} + onClick={ + configuring + ? () => { + dispatch({ + type: "CHART_ACTIVE_FIELD_CHANGED", + value: "description", + }); + } + : undefined + } /> - {/* Wrap in div for subgrid layout */} - <div className="debug-panel"> - <DebugPanel configurator interactiveFilters /> - </div> - </InteractiveFiltersChartProvider> - </LoadingStateProvider> - </> - )} - </ChartErrorBoundary> - </Box> - </MetadataPanelStoreContext.Provider> + ) : ( + // We need to have a span here to keep the space between the + // title and the chart (subgrid layout) + <span style={{ height: 1 }} /> + )} + <Box sx={{ mt: 4 }}> + {metadata?.dataCubesMetadata.some( + (d) => + d.publicationStatus === DataCubePublicationStatus.Draft + ) && ( + <Box sx={{ mb: 4 }}> + <HintYellow> + <Trans id="dataset.publicationStatus.draft.warning"> + Careful, this dataset is only a draft. + <br /> + <strong>Don't use for reporting!</strong> + </Trans> + </HintYellow> + </Box> + )} + </Box> + <ChartControls + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + metadataPanelProps={{ + components: allComponents, + top: BANNER_MARGIN_TOP, + }} + /> + <div + ref={containerRef} + style={{ + minWidth: 0, + height: containerHeight, + paddingTop: 16, + flexGrow: 1, + }} + > + {isTable ? ( + <DataSetTable + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + sx={{ width: "100%", maxHeight: "100%" }} + /> + ) : ( + <ChartWithFilters + dataSource={dataSource} + componentIris={componentIris} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + /> + )} + </div> + <ChartFootnotes + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + components={allComponents} + /> + {/* Wrap in div for subgrid layout */} + <div className="debug-panel"> + <DebugPanel configurator interactiveFilters /> + </div> + </InteractiveFiltersChartProvider> + </LoadingStateProvider> + </> + )} + </ChartErrorBoundary> + </Box> ); }; diff --git a/app/components/chart-published.tsx b/app/components/chart-published.tsx index 0507a5a0b..7e46e7a82 100644 --- a/app/components/chart-published.tsx +++ b/app/components/chart-published.tsx @@ -63,35 +63,50 @@ type ChartPublishedIndividualChartProps = ChartPublishInnerProps; const ChartPublishedIndividualChart = forwardRef< HTMLDivElement, ChartPublishedIndividualChartProps ->(({ dataSource, state, chartConfig, configKey, children, ...rest }, ref) => { - return ( - <ChartTablePreviewProvider key={chartConfig.key}> - <ChartWrapper - key={chartConfig.key} - layoutType={state.layout.type} - ref={ref} - chartKey={chartConfig.key} - {...rest} - > - <ChartPublishedInner +>( + ( + { + dataSource, + state, + chartConfig, + configKey, + metadataPanelStore, + children, + ...rest + }, + ref + ) => { + return ( + <ChartTablePreviewProvider key={chartConfig.key}> + <ChartWrapper key={chartConfig.key} - dataSource={dataSource} - state={state} - chartConfig={chartConfig} - configKey={configKey} + layoutType={state.layout.type} + ref={ref} + chartKey={chartConfig.key} + {...rest} > - {children} - </ChartPublishedInner> - </ChartWrapper> - </ChartTablePreviewProvider> - ); -}); + <ChartPublishedInner + key={chartConfig.key} + dataSource={dataSource} + state={state} + chartConfig={chartConfig} + configKey={configKey} + metadataPanelStore={metadataPanelStore} + > + {children} + </ChartPublishedInner> + </ChartWrapper> + </ChartTablePreviewProvider> + ); + } +); export const ChartPublished = (props: ChartPublishedProps) => { const { configKey } = props; const [state] = useConfiguratorState(isPublished); const { dataSource } = state; const locale = useLocale(); + const metadataPanelStore = useMemo(() => createMetadataPanelStore(), []); const renderChart = useCallback( (chartConfig: ChartConfig) => ( <ChartPublishedIndividualChart @@ -100,74 +115,78 @@ export const ChartPublished = (props: ChartPublishedProps) => { state={state} chartConfig={chartConfig} configKey={configKey} + metadataPanelStore={metadataPanelStore} /> ), - [configKey, dataSource, state] + [configKey, dataSource, metadataPanelStore, state] ); return ( - <InteractiveFiltersProvider chartConfigs={state.chartConfigs}> - {state.layout.type === "dashboard" ? ( - <> - <Box - sx={{ - mb: - state.layout.meta.title[locale] || - state.layout.meta.description[locale] - ? 4 - : 0, - }} - > - {state.layout.meta.title[locale] && ( - <Title text={state.layout.meta.title[locale]} /> - )} - {state.layout.meta.description[locale] && ( - <Description text={state.layout.meta.description[locale]} /> - )} - </Box> + <MetadataPanelStoreContext.Provider value={metadataPanelStore}> + <InteractiveFiltersProvider chartConfigs={state.chartConfigs}> + {state.layout.type === "dashboard" ? ( + <> + <Box + sx={{ + mb: + state.layout.meta.title[locale] || + state.layout.meta.description[locale] + ? 4 + : 0, + }} + > + {state.layout.meta.title[locale] && ( + <Title text={state.layout.meta.title[locale]} /> + )} + {state.layout.meta.description[locale] && ( + <Description text={state.layout.meta.description[locale]} /> + )} + </Box> - <ChartPanelLayout - layoutType={state.layout.layout} - chartConfigs={state.chartConfigs} - renderChart={renderChart} - /> - </> - ) : ( - <> - <Flex - sx={{ - flexDirection: "column", - mb: - state.layout.meta.title[locale] || - state.layout.meta.description[locale] - ? 4 - : 0, - }} - > - {state.layout.meta.title[locale] && ( - <Title text={state.layout.meta.title[locale]} /> - )} - {state.layout.meta.description[locale] && ( - <Description text={state.layout.meta.description[locale]} /> - )} - </Flex> - <ChartTablePreviewProvider> - <DashboardInteractiveFilters /> - <ChartWrapper - layoutType={state.layout.type} - chartKey={state.activeChartKey} + <ChartPanelLayout + layoutType={state.layout.layout} + chartConfigs={state.chartConfigs} + renderChart={renderChart} + /> + </> + ) : ( + <> + <Flex + sx={{ + flexDirection: "column", + mb: + state.layout.meta.title[locale] || + state.layout.meta.description[locale] + ? 4 + : 0, + }} > - <ChartPublishedInner - dataSource={dataSource} - state={state} - chartConfig={getChartConfig(state)} - configKey={configKey} - /> - </ChartWrapper> - </ChartTablePreviewProvider> - </> - )} - </InteractiveFiltersProvider> + {state.layout.meta.title[locale] && ( + <Title text={state.layout.meta.title[locale]} /> + )} + {state.layout.meta.description[locale] && ( + <Description text={state.layout.meta.description[locale]} /> + )} + </Flex> + <ChartTablePreviewProvider> + <DashboardInteractiveFilters sx={{ mb: 4 }} /> + <ChartWrapper + layoutType={state.layout.type} + chartKey={state.activeChartKey} + > + <ChartPublishedInner + dataSource={dataSource} + state={state} + chartConfig={getChartConfig(state)} + configKey={configKey} + metadataPanelStore={metadataPanelStore} + /> + </ChartWrapper> + </ChartTablePreviewProvider> + </> + )} + </InteractiveFiltersProvider> + </MetadataPanelStoreContext.Provider> ); }; @@ -190,6 +209,7 @@ type ChartPublishInnerProps = { configKey: string | undefined; className?: string; children?: React.ReactNode; + metadataPanelStore: ReturnType<typeof createMetadataPanelStore>; }; const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { @@ -200,12 +220,12 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { configKey, className, children, + metadataPanelStore, } = props; const { meta } = chartConfig; const rootRef = useRef<HTMLDivElement>(null); const { isTable, containerRef, containerHeight, computeContainerHeight } = useChartTablePreview(); - const metadataPanelStore = useMemo(() => createMetadataPanelStore(), []); const metadataPanelOpen = useStore(metadataPanelStore, (state) => state.open); const shouldShrink = useMemo(() => { const rootWidth = rootRef.current?.getBoundingClientRect().width; @@ -268,93 +288,71 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { }, [dimensions, measures]); return ( - <MetadataPanelStoreContext.Provider value={metadataPanelStore}> - <Box - className={clsx( - chartClasses.root, - publishedChartClasses.root, - className - )} - ref={rootRef} - > - {children} - <ChartErrorBoundary resetKeys={[chartConfig]}> - <div> - {metadata?.some( - (d) => d.publicationStatus === DataCubePublicationStatus.Draft - ) && ( - <Box sx={{ mb: 4 }}> - <HintRed> - <Trans id="dataset.publicationStatus.draft.warning"> - Careful, this dataset is only a draft. - <br /> - <strong>Don't use for reporting!</strong> - </Trans> - </HintRed> - </Box> - )} - {metadata?.some((d) => d.expires) && ( - <Box sx={{ mb: 4 }}> - <HintRed> - <Trans id="dataset.publicationStatus.expires.warning"> - Careful, the data for this chart has expired. - <br /> - <strong>Don't use for reporting!</strong> - </Trans> - </HintRed> - </Box> - )} - {!isTrustedDataSource && ( - <Box sx={{ mb: 4 }}> - <HintYellow> - <Trans id="data.source.notTrusted"> - This chart is not using a trusted data source. - </Trans> - </HintYellow> - </Box> - )} - {isUsingImputation(chartConfig) && ( - <Box sx={{ mb: 4 }}> - <HintBlue> - <Trans id="dataset.hasImputedValues"> - Some data in this dataset is missing and has been - interpolated to fill the gaps. - </Trans> - </HintBlue> - </Box> - )} - </div> - <LoadingStateProvider> - <InteractiveFiltersChartProvider chartConfigKey={chartConfig.key}> - <Flex - sx={{ - height: "fit-content", - justifyContent: meta.title[locale] - ? "space-between" - : "flex-end", - gap: 2, - }} - > - {meta.title[locale] ? ( - <Title - text={meta.title[locale]} - smaller={state.layout.type === "dashboard"} - /> - ) : ( - // We need to have a span here to keep the space between the - // title and the chart (subgrid layout) - <span style={{ height: 1 }} /> - )} - <Box sx={{ mt: "-0.33rem" }}> - <ChartMoreButton - configKey={configKey} - chartKey={chartConfig.key} - /> - </Box> - </Flex> - {meta.description[locale] ? ( - <Description - text={meta.description[locale]} + <Box + className={clsx(chartClasses.root, publishedChartClasses.root, className)} + ref={rootRef} + > + {children} + <ChartErrorBoundary resetKeys={[chartConfig]}> + <div> + {metadata?.some( + (d) => d.publicationStatus === DataCubePublicationStatus.Draft + ) && ( + <Box sx={{ mb: 4 }}> + <HintRed> + <Trans id="dataset.publicationStatus.draft.warning"> + Careful, this dataset is only a draft. + <br /> + <strong>Don't use for reporting!</strong> + </Trans> + </HintRed> + </Box> + )} + {metadata?.some((d) => d.expires) && ( + <Box sx={{ mb: 4 }}> + <HintRed> + <Trans id="dataset.publicationStatus.expires.warning"> + Careful, the data for this chart has expired. + <br /> + <strong>Don't use for reporting!</strong> + </Trans> + </HintRed> + </Box> + )} + {!isTrustedDataSource && ( + <Box sx={{ mb: 4 }}> + <HintYellow> + <Trans id="data.source.notTrusted"> + This chart is not using a trusted data source. + </Trans> + </HintYellow> + </Box> + )} + {isUsingImputation(chartConfig) && ( + <Box sx={{ mb: 4 }}> + <HintBlue> + <Trans id="dataset.hasImputedValues"> + Some data in this dataset is missing and has been interpolated + to fill the gaps. + </Trans> + </HintBlue> + </Box> + )} + </div> + <LoadingStateProvider> + <InteractiveFiltersChartProvider chartConfigKey={chartConfig.key}> + <Flex + sx={{ + height: "fit-content", + justifyContent: meta.title[locale] + ? "space-between" + : "flex-end", + gap: 2, + }} + > + {meta.title[locale] ? ( + <Title + text={meta.title[locale]} smaller={state.layout.type === "dashboard"} /> ) : ( @@ -362,51 +360,71 @@ const ChartPublishedInnerImpl = (props: ChartPublishInnerProps) => { // title and the chart (subgrid layout) <span style={{ height: 1 }} /> )} - <ChartControls - dataSource={dataSource} - chartConfig={chartConfig} - metadataPanelProps={{ - components: allComponents, - container: rootRef.current, - allowMultipleOpen: true, - }} + <Box sx={{ mt: "-0.33rem" }}> + <ChartMoreButton + configKey={configKey} + chartKey={chartConfig.key} + /> + </Box> + </Flex> + {meta.description[locale] ? ( + <Description + text={meta.description[locale]} + smaller={state.layout.type === "dashboard"} /> + ) : ( + // We need to have a span here to keep the space between the + // title and the chart (subgrid layout) + <span style={{ height: 1 }} /> + )} + <ChartControls + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + metadataPanelProps={{ + components: allComponents, + container: rootRef.current, + allowMultipleOpen: true, + }} + /> - <div - ref={containerRef} - style={{ - // TODO before merging, Align with chart-preview - minWidth: 0, - height: containerHeight, - marginTop: 16, - flexGrow: 1, - }} - > - {isTable ? ( - <DataSetTable - dataSource={dataSource} - chartConfig={chartConfig} - sx={{ maxHeight: "100%" }} - /> - ) : ( - <ChartWithFilters - dataSource={dataSource} - componentIris={componentIris} - chartConfig={chartConfig} - /> - )} - </div> - <ChartFootnotes - dataSource={dataSource} - chartConfig={chartConfig} - components={allComponents} - showVisualizeLink={state.chartConfigs.length === 1} - /> - </InteractiveFiltersChartProvider> - </LoadingStateProvider> - </ChartErrorBoundary> - </Box> - </MetadataPanelStoreContext.Provider> + <div + ref={containerRef} + style={{ + // TODO before merging, Align with chart-preview + minWidth: 0, + height: containerHeight, + marginTop: 16, + flexGrow: 1, + }} + > + {isTable ? ( + <DataSetTable + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + sx={{ maxHeight: "100%" }} + /> + ) : ( + <ChartWithFilters + dataSource={dataSource} + componentIris={componentIris} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + /> + )} + </div> + <ChartFootnotes + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} + components={allComponents} + showVisualizeLink={state.chartConfigs.length === 1} + /> + </InteractiveFiltersChartProvider> + </LoadingStateProvider> + </ChartErrorBoundary> + </Box> ); }; diff --git a/app/components/chart-shared.tsx b/app/components/chart-shared.tsx index 34c371ef7..6b4bc2a8d 100644 --- a/app/components/chart-shared.tsx +++ b/app/components/chart-shared.tsx @@ -15,6 +15,7 @@ import { MenuActionItem } from "@/components/menu-action-item"; import { MetadataPanel } from "@/components/metadata-panel"; import { ChartConfig, + DashboardFiltersConfig, DataSource, getChartConfig, hasChartConfigs, @@ -50,19 +51,27 @@ export const useChartStyles = makeStyles<Theme>((theme) => ({ export const ChartControls = ({ dataSource, chartConfig, + dashboardFilters, metadataPanelProps, }: { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; metadataPanelProps: Omit< ComponentProps<typeof MetadataPanel>, - "dataSource" | "chartConfig" + "dataSource" | "chartConfig" | "dashboardFilters" >; }) => { - const showFilters = chartConfig.interactiveFiltersConfig?.dataFilters.active; + const showFilters = + chartConfig.interactiveFiltersConfig?.dataFilters.active && + chartConfig.interactiveFiltersConfig.dataFilters.componentIris.some( + (componentIri) => + !dashboardFilters?.dataFilters.componentIris.includes(componentIri) + ); const chartFiltersState = useChartDataFiltersState({ dataSource, chartConfig, + dashboardFilters, }); return ( <Box @@ -87,6 +96,7 @@ export const ChartControls = ({ <MetadataPanel dataSource={dataSource} chartConfig={chartConfig} + dashboardFilters={dashboardFilters} {...metadataPanelProps} /> </Box> diff --git a/app/components/chart-with-filters.tsx b/app/components/chart-with-filters.tsx index 09bb714a8..3ee25c806 100644 --- a/app/components/chart-with-filters.tsx +++ b/app/components/chart-with-filters.tsx @@ -5,7 +5,11 @@ import { forwardRef } from "react"; import { useQueryFilters } from "@/charts/shared/chart-helpers"; import { Observer } from "@/charts/shared/use-size"; import useSyncInteractiveFilters from "@/charts/shared/use-sync-interactive-filters"; -import { ChartConfig, DataSource } from "@/configurator"; +import { + ChartConfig, + DashboardFiltersConfig, + DataSource, +} from "@/configurator"; const ChartAreasVisualization = dynamic( import("@/charts/area/chart-area").then( @@ -72,14 +76,17 @@ type GenericChartProps = { dataSource: DataSource; componentIris: string[] | undefined; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; }; const GenericChart = (props: GenericChartProps) => { - const { dataSource, componentIris, chartConfig } = props; + const { dataSource, componentIris, chartConfig, dashboardFilters } = props; const observationQueryFilters = useQueryFilters({ chartConfig, + dashboardFilters, componentIris, }); + const commonProps = { dataSource, observationQueryFilters, @@ -150,6 +157,7 @@ type ChartWithFiltersProps = { dataSource: DataSource; componentIris: string[] | undefined; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; }; const useStyles = makeStyles(() => ({ @@ -163,9 +171,8 @@ export const ChartWithFilters = forwardRef< HTMLDivElement, ChartWithFiltersProps >((props, ref) => { - useSyncInteractiveFilters(props.chartConfig); + useSyncInteractiveFilters(props.chartConfig, props.dashboardFilters); const classes = useStyles(); - return ( <div className={classes.chartWithFilters} ref={ref}> <Observer> diff --git a/app/components/dashboard-interactive-filters.spec.tsx b/app/components/dashboard-interactive-filters.spec.tsx new file mode 100644 index 000000000..a0e843ba5 --- /dev/null +++ b/app/components/dashboard-interactive-filters.spec.tsx @@ -0,0 +1,102 @@ +import { saveDataFiltersSnapshot } from "@/components/dashboard-interactive-filters"; +import { ChartConfig } from "@/configurator"; +import { + InteractiveFiltersContextValue, + setDataFilter, +} from "@/stores/interactive-filters"; + +class MockState<T = unknown> { + private _state: any; + + constructor(state: T) { + this._state = state; + } + + getState() { + return this._state; + } + + setState(newState: any) { + Object.assign(this._state, newState); + } +} +const mockDashboardInteractiveFiltersContextValue = <T extends unknown>( + state: T +) => { + return [ + null, + null, + new MockState(state), + ] as unknown as InteractiveFiltersContextValue; +}; +test("Save snapshot, modify the store manually, then restore and check result", () => { + // Mock chartConfigs + const chartConfigs: ChartConfig[] = [ + { key: "chart1" } as ChartConfig, + { key: "chart2" } as ChartConfig, + { key: "chart3" } as ChartConfig, + ]; + + // Mock stores + const stores = { + chart1: mockDashboardInteractiveFiltersContextValue({ + unrelated: 1, + dataFilters: { component1: { type: "single", value: "filter1" } }, + }), + chart2: mockDashboardInteractiveFiltersContextValue({ + dataFilters: { component2: { type: "single", value: "filter2" } }, + }), + chart3: mockDashboardInteractiveFiltersContextValue({ + dataFilters: { component3: { type: "single", value: "filter3" } }, + }), + }; + + // Save the snapshot + const restoreComponent1 = saveDataFiltersSnapshot( + chartConfigs, + stores, + "component1" + ); + + // Manually modify the store + setDataFilter(stores.chart1[2], "component1", "modified filter"); + setDataFilter(stores.chart1[2], "component2", "modified filter 2"); + + const restoreComponent3 = saveDataFiltersSnapshot( + chartConfigs, + stores, + "component3" + ); + + setDataFilter(stores.chart1[2], "component3", "modified filter 3"); + + // Restore the snapshot + restoreComponent1(); + + // Check if the store is restored correctly + expect(stores.chart1[2].getState()).toEqual({ + unrelated: 1, + dataFilters: { + // Filter 1 is restored + component1: { type: "single", value: "filter1" }, + // Filter 2 is not restored + component2: { type: "single", value: "modified filter 2" }, + // Filter 3 is not yet restored + component3: { type: "single", value: "modified filter 3" }, + }, + }); + + restoreComponent3(); + + // Check if the store is restored correctly + expect(stores.chart1[2].getState()).toEqual({ + unrelated: 1, + dataFilters: { + // Filter 1 is restored + component1: { type: "single", value: "filter1" }, + // Filter 2 is not restored + component2: { type: "single", value: "modified filter 2" }, + // Filter 3 is not there anymore + }, + }); +}); diff --git a/app/components/dashboard-interactive-filters.tsx b/app/components/dashboard-interactive-filters.tsx index f0f186559..42a0a3df0 100644 --- a/app/components/dashboard-interactive-filters.tsx +++ b/app/components/dashboard-interactive-filters.tsx @@ -1,33 +1,70 @@ import { - Collapse, + Box, + BoxProps, + SelectChangeEvent, Slider, sliderClasses, useEventCallback, } from "@mui/material"; import { Theme } from "@mui/material/styles"; import { makeStyles } from "@mui/styles"; -import { useEffect, useMemo, useState } from "react"; +import uniq from "lodash/uniq"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; import { + DataFilterGenericDimension, + DataFilterHierarchyDimension, + DataFilterTemporalDimension, +} from "@/charts/shared/chart-data-filters"; +import { + ChartConfig, DashboardTimeRangeFilter, + hasChartConfigs, InteractiveFiltersTimeRange, + useConfiguratorState, } from "@/configurator"; import { timeUnitToFormatter, timeUnitToParser, } from "@/configurator/components/ui-helpers"; +import { isTemporalDimension } from "@/domain/data"; +import { useDataCubesComponentsQuery } from "@/graphql/hooks"; import { TimeUnit } from "@/graphql/query-hooks"; -import { useDashboardInteractiveFilters } from "@/stores/interactive-filters"; +import { useLocale } from "@/locales/use-locale"; +import { + setDataFilter, + useDashboardInteractiveFilters, +} from "@/stores/interactive-filters"; import { useTransitionStore } from "@/stores/transition"; import { assert } from "@/utils/assert"; +import useEvent from "@/utils/use-event"; import { useTimeout } from "../hooks/use-timeout"; -const useStyles = makeStyles((theme: Theme) => ({ +export const DashboardInteractiveFilters = (props: BoxProps) => { + const [state] = useConfiguratorState(hasChartConfigs); + const { dashboardFilters } = state; + return ( + <Box {...props}> + {dashboardFilters?.timeRange.active ? ( + <DashboardTimeRangeSlider + filter={dashboardFilters.timeRange} + mounted={dashboardFilters.timeRange.active} + /> + ) : null} + {dashboardFilters?.dataFilters.componentIris.length ? ( + <DashboardDataFilters + componentIris={dashboardFilters.dataFilters.componentIris} + /> + ) : null} + </Box> + ); +}; + +const useTimeRangeRangeStyles = makeStyles((theme: Theme) => ({ slider: { maxWidth: 800, margin: theme.spacing(6, 4, 2), - [`& .${sliderClasses.track}`]: { height: 1, }, @@ -78,7 +115,7 @@ const DashboardTimeRangeSlider = ({ filter: DashboardTimeRangeFilter; mounted: boolean; }) => { - const classes = useStyles(); + const classes = useTimeRangeRangeStyles(); const dashboardInteractiveFilters = useDashboardInteractiveFilters(); const setEnableTransition = useTransitionStore((state) => state.setEnable); const presets = filter.presets; @@ -180,20 +217,6 @@ const DashboardTimeRangeSlider = ({ ); }; -export const DashboardInteractiveFilters = () => { - const { timeRange } = useDashboardInteractiveFilters(); - return timeRange?.active ? ( - <Collapse in={timeRange.active}> - <div> - <DashboardTimeRangeSlider - filter={timeRange} - mounted={timeRange.active} - /> - </div> - </Collapse> - ) : null; -}; - function stepFromTimeUnit(timeUnit: TimeUnit | undefined) { if (!timeUnit) { return 0; @@ -216,3 +239,204 @@ function stepFromTimeUnit(timeUnit: TimeUnit | undefined) { return 1; } } + +const useDataFilterStyles = makeStyles((theme: Theme) => ({ + wrapper: { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", + gap: theme.spacing(2), + }, + filter: { + display: "flex", + flex: "1 1 100%", + width: "100%", + marginRight: theme.spacing(3), + "&:last-of-type": { + marginRight: 0, + }, + "& > div": { + width: "100%", + }, + }, +})); + +export type Stores = ReturnType< + typeof useDashboardInteractiveFilters +>["stores"]; + +export const saveDataFiltersSnapshot = ( + chartConfigs: ChartConfig[], + stores: Stores, + componentIri: string +) => { + const snapshot = Object.fromEntries( + Object.entries(stores).map(([key, [_getState, _useStore, store]]) => { + const state = store.getState(); + const filterValue = state.dataFilters[componentIri]; + return [key, filterValue]; + }) + ); + + return () => { + for (const [chartKey, [_getState, _useStore, store]] of Object.entries( + stores + )) { + if (chartConfigs.map((config) => config.key).includes(chartKey)) { + const dataFilters = store.getState().dataFilters; + const filterValue = snapshot[chartKey]; + if (filterValue) { + dataFilters[componentIri] = filterValue; + store.setState({ dataFilters }); + } else { + delete dataFilters[componentIri]; + store.setState({ dataFilters }); + } + } + } + }; +}; + +const DashboardDataFilters = ({ + componentIris, +}: { + componentIris: string[]; +}) => { + const classes = useDataFilterStyles(); + return ( + <div className={classes.wrapper}> + {componentIris.map((componentIri) => ( + <DataFilter key={componentIri} componentIri={componentIri} /> + ))} + </div> + ); +}; + +const DataFilter = ({ componentIri }: { componentIri: string }) => { + const locale = useLocale(); + const classes = useDataFilterStyles(); + const [{ chartConfigs, dataSource, dashboardFilters }] = + useConfiguratorState(hasChartConfigs); + const dashboardInteractiveFilters = useDashboardInteractiveFilters(); + const relevantChartConfigs = chartConfigs.filter((config) => + config.cubes.some((cube) => + Object.keys(cube.filters).includes(componentIri) + ) + ); + const cubeIris = uniq( + chartConfigs.flatMap((config) => + config.cubes + .filter((cube) => Object.keys(cube.filters).includes(componentIri)) + .map((cube) => cube.iri) + ) + ); + + if (cubeIris.length > 1) { + console.error( + `Data filter ${componentIri} is used in multiple cubes: ${cubeIris.join(", ")}` + ); + } + + const cubeIri = cubeIris[0]; + + const [{ data }] = useDataCubesComponentsQuery({ + variables: { + sourceType: dataSource.type, + sourceUrl: dataSource.url, + locale, + cubeFilters: [ + { iri: cubeIri, componentIris: [componentIri], loadValues: true }, + ], + }, + keepPreviousData: true, + }); + + const dimension = data?.dataCubesComponents.dimensions[0]; + + const [value, setValue] = useState<string>(); + const handleChange = useEvent( + ( + e: + | SelectChangeEvent<unknown> + | ChangeEvent<HTMLSelectElement> + | { target: { value: string } } + ) => { + const newValue = e.target.value as string; + setValue(newValue); + + for (const [chartKey, [_getState, _useStore, store]] of Object.entries( + dashboardInteractiveFilters.stores + )) { + if ( + relevantChartConfigs.map((config) => config.key).includes(chartKey) + ) { + setDataFilter(store, componentIri, newValue); + } + } + } + ); + + // Syncs the interactive filter value with the config value + useEffect(() => { + const value = dashboardFilters?.dataFilters.filters[componentIri].value as + | string + | undefined; + if (value) { + handleChange({ target: { value } }); + } + }, [componentIri, handleChange, dashboardFilters?.dataFilters.filters]); + + useEffect(() => { + const restoreSnapshot = saveDataFiltersSnapshot( + relevantChartConfigs, + dashboardInteractiveFilters.stores, + componentIri + ); + + const value = dashboardFilters?.dataFilters.filters[componentIri]?.value as + | string + | undefined; + + if (value) { + handleChange({ target: { value } } as ChangeEvent<HTMLSelectElement>); + } else if (dimension?.values.length) { + handleChange({ + target: { value: dimension.values[0].value as string }, + } as ChangeEvent<HTMLSelectElement>); + } + + return () => { + restoreSnapshot(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dimension?.values]); + const disabled = !dimension?.values.length; + const hierarchy = dimension?.hierarchy; + + return dimension && value ? ( + <div className={classes.filter}> + {isTemporalDimension(dimension) ? ( + <DataFilterTemporalDimension + value={value} + dimension={dimension} + onChange={handleChange} + disabled={disabled} + /> + ) : hierarchy ? ( + <DataFilterHierarchyDimension + value={value} + dimension={dimension} + onChange={handleChange} + hierarchy={hierarchy} + disabled={disabled} + /> + ) : ( + <DataFilterGenericDimension + value={value} + dimension={dimension} + onChange={handleChange} + disabled={disabled} + /> + )} + </div> + ) : null; +}; diff --git a/app/components/metadata-panel.tsx b/app/components/metadata-panel.tsx index 60183dfca..880b56d1f 100644 --- a/app/components/metadata-panel.tsx +++ b/app/components/metadata-panel.tsx @@ -45,7 +45,12 @@ import { useMetadataPanelStoreActions, } from "@/components/metadata-panel-store"; import { MotionBox } from "@/components/presence"; -import { BackButton, ChartConfig, DataSource } from "@/configurator"; +import { + BackButton, + ChartConfig, + DashboardFiltersConfig, + DataSource, +} from "@/configurator"; import { DRAWER_WIDTH } from "@/configurator/components/drawer"; import { getComponentDescription, @@ -229,6 +234,7 @@ export const OpenMetadataPanelWrapper = ({ export const MetadataPanel = ({ chartConfig, + dashboardFilters, dataSource, components, container, @@ -237,6 +243,7 @@ export const MetadataPanel = ({ renderToggle = true, }: { chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; dataSource: DataSource; components: Component[]; container?: HTMLDivElement | null; @@ -327,7 +334,11 @@ export const MetadataPanel = ({ <AnimatePresence> {activeSection === "general" ? ( <MotionBox key="cubes-panel" {...animationProps}> - <CubesPanel dataSource={dataSource} chartConfig={chartConfig} /> + <CubesPanel + dataSource={dataSource} + chartConfig={chartConfig} + dashboardFilters={dashboardFilters} + /> </MotionBox> ) : activeSection === "data" ? ( <MotionBox key="data-panel" {...animationProps}> @@ -380,9 +391,11 @@ const Header = ({ onClose }: { onClose: () => void }) => { const CubesPanel = ({ dataSource, chartConfig, + dashboardFilters, }: { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; }) => { const classes = useOtherStyles(); const locale = useLocale(); @@ -404,6 +417,7 @@ const CubesPanel = ({ const cubesMetadata = dataCubesMetadataData?.dataCubesMetadata; const queryFilters = useQueryFilters({ chartConfig, + dashboardFilters, componentIris: extractChartConfigComponentIris({ chartConfig }), }); const [ diff --git a/app/components/select-tree.tsx b/app/components/select-tree.tsx index aae3508c6..27827b8e0 100644 --- a/app/components/select-tree.tsx +++ b/app/components/select-tree.tsx @@ -269,7 +269,7 @@ export type SelectTreeProps = { value: NodeId | undefined; topControls?: React.ReactNode; sideControls?: React.ReactNode; - onChange: (ev: { target: { value: NodeId } }) => void; + onChange: (e: { target: { value: NodeId } }) => void; disabled?: boolean; label?: React.ReactNode; onOpen?: () => void; diff --git a/app/config-types.ts b/app/config-types.ts index e71b84055..f1cf91560 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1150,16 +1150,30 @@ const DashboardTimeRangeFilter = t.type({ to: t.string, }), }); - export type DashboardTimeRangeFilter = t.TypeOf< typeof DashboardTimeRangeFilter >; +const DashboardDataFiltersConfig = t.type({ + componentIris: t.array(t.string), + filters: SingleFilters, +}); +export type DashboardDataFiltersConfig = t.TypeOf< + typeof DashboardDataFiltersConfig +>; + const DashboardFiltersConfig = t.type({ timeRange: DashboardTimeRangeFilter, + dataFilters: DashboardDataFiltersConfig, }); export type DashboardFiltersConfig = t.TypeOf<typeof DashboardFiltersConfig>; +export const areDataFiltersActive = ( + dashboardFilters: DashboardFiltersConfig | undefined +) => { + return dashboardFilters?.dataFilters.componentIris.length; +}; + const Config = t.intersection([ t.type( { diff --git a/app/configurator/components/add-dataset-dialog.mock.ts b/app/configurator/components/add-dataset-dialog.mock.ts index c36b35ed4..0695372f6 100644 --- a/app/configurator/components/add-dataset-dialog.mock.ts +++ b/app/configurator/components/add-dataset-dialog.mock.ts @@ -109,5 +109,9 @@ export const photovoltaikChartStateMock: ConfiguratorStateConfiguringChart = { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }; diff --git a/app/configurator/components/chart-configurator.tsx b/app/configurator/components/chart-configurator.tsx index 6963c3d29..696e0be89 100644 --- a/app/configurator/components/chart-configurator.tsx +++ b/app/configurator/components/chart-configurator.tsx @@ -40,6 +40,7 @@ import { ChartConfig, ConfiguratorStateConfiguringChart, ConfiguratorStatePublishing, + DashboardFiltersConfig, DataSource, Filters, getChartConfig, @@ -98,24 +99,23 @@ import useEvent from "@/utils/use-event"; import { FiltersBadge } from "./badges"; import { DatasetsControlSection } from "./dataset-control-section"; -type DataFilterSelectGenericProps = { +export const DataFilterSelectGeneric = ({ + rawDimension, + filterDimensionIris, + index, + disabled, + onRemove, + sideControls, + disableLabel, +}: { rawDimension: Dimension; filterDimensionIris: string[]; index: number; disabled?: boolean; - onRemove: () => void; + onRemove?: () => void; sideControls?: React.ReactNode; -}; - -const DataFilterSelectGeneric = (props: DataFilterSelectGenericProps) => { - const { - rawDimension, - filterDimensionIris, - index, - disabled, - onRemove, - sideControls, - } = props; + disableLabel?: boolean; +}) => { const locale = useLocale(); const [state] = useConfiguratorState(); const chartConfig = getChartConfig(state); @@ -156,7 +156,7 @@ const DataFilterSelectGeneric = (props: DataFilterSelectGenericProps) => { const sharedProps = { dimension, - label: ( + label: disableLabel ? null : ( <OpenMetadataPanelWrapper component={dimension}> <span>{`${index + 1}. ${dimension.label}`}</span> </OpenMetadataPanelWrapper> @@ -422,7 +422,7 @@ const useFilterReorder = ({ onAddDimensionFilter?.(); const filterValue = dimension.values[0]; dispatch({ - type: "CHART_CONFIG_FILTER_SET_SINGLE", + type: "FILTER_SET_SINGLE", value: { filters: dimensionToFieldProps(dimension), value: `${filterValue.value}`, @@ -432,7 +432,7 @@ const useFilterReorder = ({ const handleRemoveDimensionFilter = useEvent((dimension: Dimension) => { dispatch({ - type: "CHART_CONFIG_FILTER_REMOVE_SINGLE", + type: "FILTER_REMOVE_SINGLE", value: { filters: dimensionToFieldProps(dimension), }, @@ -629,6 +629,7 @@ export const ChartConfigurator = ({ <ChartFields dataSource={state.dataSource} chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} dimensions={dimensions} measures={measures} /> @@ -755,6 +756,7 @@ export const ChartConfigurator = ({ <MetadataPanel dataSource={state.dataSource} chartConfig={chartConfig} + dashboardFilters={state.dashboardFilters} components={components} top={HEADER_HEIGHT} renderToggle={false} @@ -766,14 +768,16 @@ export const ChartConfigurator = ({ type ChartFieldsProps = { dataSource: DataSource; chartConfig: ChartConfig; + dashboardFilters: DashboardFiltersConfig | undefined; dimensions?: Dimension[]; measures?: Measure[]; }; const ChartFields = (props: ChartFieldsProps) => { - const { dataSource, chartConfig, dimensions, measures } = props; + const { dataSource, chartConfig, dashboardFilters, dimensions, measures } = + props; const components = [...(dimensions ?? []), ...(measures ?? [])]; - const queryFilters = useQueryFilters({ chartConfig }); + const queryFilters = useQueryFilters({ chartConfig, dashboardFilters }); const locale = useLocale(); const [{ data: observationsData }] = useDataCubesObservationsQuery({ variables: { diff --git a/app/configurator/components/chart-options-selector.tsx b/app/configurator/components/chart-options-selector.tsx index 8f9b5d170..f134f79ba 100644 --- a/app/configurator/components/chart-options-selector.tsx +++ b/app/configurator/components/chart-options-selector.tsx @@ -121,7 +121,10 @@ export const ChartOptionsSelector = ({ }); const dimensions = componentsData?.dataCubesComponents.dimensions; const measures = componentsData?.dataCubesComponents.measures; - const queryFilters = useQueryFilters({ chartConfig }); + const queryFilters = useQueryFilters({ + chartConfig, + dashboardFilters: state.dashboardFilters, + }); const [{ data: observationsData, fetching: fetchingObservations }] = useDataCubesObservationsQuery({ variables: { diff --git a/app/configurator/components/field.tsx b/app/configurator/components/field.tsx index 3777000c5..b8975c1fb 100644 --- a/app/configurator/components/field.tsx +++ b/app/configurator/components/field.tsx @@ -335,16 +335,6 @@ export const MostRecentDateSwitch = (props: MostRecentDateSwitchProps) => { ); }; -type DataFilterTemporalProps = { - label: React.ReactNode; - dimension: TemporalDimension; - timeUnit: DatePickerTimeUnit; - disabled?: boolean; - isOptional?: boolean; - topControls?: React.ReactNode; - sideControls?: React.ReactNode; -}; - export const dimensionToFieldProps = (dim: Component) => { return isJoinByComponent(dim) ? dim.originalIris.map((o) => pick(o, ["cubeIri", "dimensionIri"])) @@ -356,16 +346,23 @@ export const dimensionToFieldProps = (dim: Component) => { ]; }; -export const DataFilterTemporal = (props: DataFilterTemporalProps) => { - const { - label: _label, - dimension, - timeUnit, - disabled, - isOptional, - topControls, - sideControls, - } = props; +export const DataFilterTemporal = ({ + label: _label, + dimension, + timeUnit, + disabled, + isOptional, + topControls, + sideControls, +}: { + label: React.ReactNode; + dimension: TemporalDimension; + timeUnit: DatePickerTimeUnit; + disabled?: boolean; + isOptional?: boolean; + topControls?: React.ReactNode; + sideControls?: React.ReactNode; +}) => { const { values, timeFormat } = dimension; const formatLocale = useTimeFormatLocale(); const formatDate = formatLocale.format(timeFormat); diff --git a/app/configurator/components/layout-configurator.tsx b/app/configurator/components/layout-configurator.tsx index 2124f383e..9ece9f65f 100644 --- a/app/configurator/components/layout-configurator.tsx +++ b/app/configurator/components/layout-configurator.tsx @@ -1,7 +1,6 @@ import { t, Trans } from "@lingui/macro"; import { Box, - FormControlLabel, Stack, Switch, SwitchProps, @@ -24,6 +23,7 @@ import { LayoutDashboard, } from "@/config-types"; import { LayoutAnnotator } from "@/configurator/components/annotators"; +import { DataFilterSelectGeneric } from "@/configurator/components/chart-configurator"; import { ControlSection, ControlSectionContent, @@ -116,22 +116,26 @@ const LayoutLayoutConfigurator = () => { const LayoutSharedFiltersConfigurator = () => { const [state, dispatch] = useConfiguratorState(isLayouting); const { layout } = state; - const { timeRange, potentialTimeRangeFilterIris } = + const { potentialTimeRangeFilterIris, potentialDataFilterIris } = useDashboardInteractiveFilters(); + const { timeRange, dataFilters } = state.dashboardFilters ?? {}; const locale = useLocale(); - const [{ data }] = useConfigsCubeComponents({ + const [{ data, fetching }] = useConfigsCubeComponents({ variables: { state, locale: locale, }, }); + const dimensions = useMemo( + () => data?.dataCubesComponents.dimensions ?? [], + [data?.dataCubesComponents.dimensions] + ); const formatLocale = useTimeFormatLocale(); const timeFormatUnit = useTimeFormatUnit(); const combinedDimension = useMemo(() => { - const dimensions = data?.dataCubesComponents.dimensions ?? []; const timeUnitDimensions = dimensions.filter( (dimension) => isTemporalDimensionWithTimeUnit(dimension) && @@ -179,13 +183,9 @@ const LayoutSharedFiltersConfigurator = () => { }; return combinedDimension; - }, [ - data?.dataCubesComponents.dimensions, - formatLocale, - potentialTimeRangeFilterIris, - ]); + }, [dimensions, formatLocale, potentialTimeRangeFilterIris]); - const handleToggle: SwitchProps["onChange"] = useEventCallback( + const handleTimeRangeFilterToggle: SwitchProps["onChange"] = useEventCallback( (_, checked) => { if (checked) { const options = getTimeFilterOptions({ @@ -194,7 +194,7 @@ const LayoutSharedFiltersConfigurator = () => { timeFormatUnit, }); - const from = options.sortedOptions[0].date; + const from = options.sortedOptions[0]?.date; const to = options.sortedOptions.at(-1)?.date; const formatDate = timeUnitToFormatter[combinedDimension.timeUnit]; @@ -221,10 +221,63 @@ const LayoutSharedFiltersConfigurator = () => { } ); + const handleDataFiltersToggle = useEventCallback( + (checked: boolean, componentIri: string) => { + if (checked) { + dispatch({ + type: "DASHBOARD_DATA_FILTERS_SET", + value: { + componentIris: dataFilters?.componentIris + ? [...dataFilters.componentIris, componentIri].sort((a, b) => { + const aIndex = + dimensions.find((d) => d.iri === a)?.order ?? + dimensions.findIndex((d) => d.iri === a) ?? + 0; + const bIndex = + dimensions.find((d) => d.iri === b)?.order ?? + dimensions.findIndex((d) => d.iri === b) ?? + 0; + return aIndex - bIndex; + }) + : [componentIri], + filters: dataFilters?.filters ?? {}, + }, + }); + const value = dimensions.find((d) => d.iri === componentIri)?.values[0] + .value as string; + dispatch({ + type: "FILTER_SET_SINGLE", + value: { + // FIXME: shared filters should be scoped per cube + filters: [{ cubeIri: "", dimensionIri: componentIri }], + value, + }, + }); + } else { + dispatch({ + type: "DASHBOARD_DATA_FILTER_REMOVE", + value: { + dimensionIri: componentIri, + }, + }); + dispatch({ + type: "FILTER_REMOVE_SINGLE", + value: { + // FIXME: shared filters should be scoped per cube + filters: [{ cubeIri: "", dimensionIri: componentIri }], + }, + }); + } + } + ); + switch (layout.type) { case "tab": case "dashboard": - if (!timeRange || potentialTimeRangeFilterIris.length === 0) { + if ( + (!timeRange || potentialTimeRangeFilterIris.length === 0) && + (!dataFilters || potentialDataFilterIris.length === 0) + ) { return null; } @@ -243,39 +296,83 @@ const LayoutSharedFiltersConfigurator = () => { </SubsectionTitle> <ControlSectionContent> <Stack gap="0.5rem"> - <Box - display="flex" - alignItems="center" - justifyContent="space-between" - width="100%" - > - <Typography variant="body2" flexGrow={1}> - {combinedDimension.label} - </Typography> - <FormControlLabel - sx={{ mr: 0 }} - labelPlacement="start" - disableTypography - label={ + {/* TODO: allow TemporalOrdinalDimensions to work here */} + {timeRange && combinedDimension.values.length ? ( + <> + <Box + sx={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + }} + > <Typography variant="body2"> - <Trans id="controls.section.shared-filters.shared-switch"> - Shared - </Trans> + {combinedDimension.label} </Typography> - } - control={ <Switch checked={timeRange.active} - onChange={handleToggle} + onChange={handleTimeRangeFilterToggle} + /> + </Box> + {timeRange.active ? ( + <DashboardFiltersOptions + timeRangeFilter={timeRange} + timeRangeCombinedDimension={combinedDimension} /> - } - /> - </Box> - {timeRange.active ? ( - <DashboardFiltersOptions - dimension={combinedDimension} - timeRangeFilter={timeRange} - /> + ) : null} + </> + ) : null} + {dataFilters ? ( + <> + {potentialDataFilterIris.map((componentIri, i) => { + const dimension = dimensions.find( + (dimension) => dimension.iri === componentIri + ); + if (!dimension) { + return null; + } + const checked = dataFilters.componentIris.includes( + dimension.iri + ); + return ( + <Box + key={dimension.iri} + sx={{ display: "flex", flexDirection: "column" }} + > + <Box + sx={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + <Typography variant="body2"> + {dimension.label} + </Typography> + <Switch + checked={checked} + onChange={(_, checked) => { + handleDataFiltersToggle(checked, dimension.iri); + }} + /> + </Box> + {checked ? ( + <Box sx={{ mb: 1 }}> + <DataFilterSelectGeneric + key={dimension.iri} + rawDimension={dimension} + filterDimensionIris={[]} + index={i} + disabled={fetching} + disableLabel + /> + </Box> + ) : null} + </Box> + ); + })} + </> ) : null} </Stack> </ControlSectionContent> @@ -288,35 +385,35 @@ const LayoutSharedFiltersConfigurator = () => { const DashboardFiltersOptions = ({ timeRangeFilter, - dimension, + timeRangeCombinedDimension, }: { timeRangeFilter: DashboardTimeRangeFilter | undefined; - dimension: Dimension; + timeRangeCombinedDimension: Dimension; }) => { if (!timeRangeFilter) { return null; } if ( - !canDimensionBeTimeFiltered(dimension) || - !canRenderDatePickerField(dimension.timeUnit) + !canDimensionBeTimeFiltered(timeRangeCombinedDimension) || + !canRenderDatePickerField(timeRangeCombinedDimension.timeUnit) ) { return null; } return ( <DashboardTimeRangeFilterOptions - timeRangeFilter={timeRangeFilter} - dimension={dimension} + filter={timeRangeFilter} + dimension={timeRangeCombinedDimension} /> ); }; const DashboardTimeRangeFilterOptions = ({ - timeRangeFilter, + filter, dimension, }: { - timeRangeFilter: DashboardTimeRangeFilter; + filter: DashboardTimeRangeFilter; dimension: TemporalDimension | TemporalEntityDimension; }) => { const { timeUnit, timeFormat } = dimension; @@ -375,9 +472,9 @@ const DashboardTimeRangeFilterOptions = ({ dispatch({ type: "DASHBOARD_TIME_RANGE_FILTER_UPDATE", value: { - ...timeRangeFilter, + ...filter, presets: { - ...timeRangeFilter.presets, + ...filter.presets, from: formatDate(newDate), }, }, @@ -391,9 +488,9 @@ const DashboardTimeRangeFilterOptions = ({ dispatch({ type: "DASHBOARD_TIME_RANGE_FILTER_UPDATE", value: { - ...timeRangeFilter, + ...filter, presets: { - ...timeRangeFilter.presets, + ...filter.presets, from: ev.target.value as string, }, }, @@ -412,9 +509,9 @@ const DashboardTimeRangeFilterOptions = ({ dispatch({ type: "DASHBOARD_TIME_RANGE_FILTER_UPDATE", value: { - ...timeRangeFilter, + ...filter, presets: { - ...timeRangeFilter.presets, + ...filter.presets, to: formatDate(newDate), }, }, @@ -428,9 +525,9 @@ const DashboardTimeRangeFilterOptions = ({ dispatch({ type: "DASHBOARD_TIME_RANGE_FILTER_UPDATE", value: { - ...timeRangeFilter, + ...filter, presets: { - ...timeRangeFilter.presets, + ...filter.presets, to: ev.target.value as string, }, }, @@ -456,7 +553,7 @@ const DashboardTimeRangeFilterOptions = ({ {canRenderDatePickerField(timeUnit) ? ( <DatePickerField name="dashboard-time-range-filter-from" - value={parseDate(timeRangeFilter.presets.from) as Date} + value={parseDate(filter.presets.from) as Date} onChange={handleChangeFromDate} isDateDisabled={(date) => !optionValues.includes(formatDate(date))} timeUnit={timeUnit} @@ -470,14 +567,14 @@ const DashboardTimeRangeFilterOptions = ({ id="dashboard-time-range-filter-from" label={t({ id: "controls.filters.select.from", message: "From" })} options={options} - value={timeRangeFilter.presets.from} + value={filter.presets.from} onChange={handleChangeFromGeneric} /> )} {canRenderDatePickerField(timeUnit) ? ( <DatePickerField name="dashboard-time-range-filter-to" - value={parseDate(timeRangeFilter.presets.to) as Date} + value={parseDate(filter.presets.to) as Date} onChange={handleChangeToDate} isDateDisabled={(date) => !optionValues.includes(formatDate(date))} timeUnit={timeUnit} @@ -491,7 +588,7 @@ const DashboardTimeRangeFilterOptions = ({ id="dashboard-time-range-filter-to" label={t({ id: "controls.filters.select.to", message: "To" })} options={options} - value={timeRangeFilter.presets.to} + value={filter.presets.to} onChange={handleChangeToGeneric} /> )} diff --git a/app/configurator/config-form.tsx b/app/configurator/config-form.tsx index 8e1b47283..c1f5dcbcb 100644 --- a/app/configurator/config-form.tsx +++ b/app/configurator/config-form.tsx @@ -2,8 +2,8 @@ import { SelectChangeEvent, SelectProps } from "@mui/material"; import get from "lodash/get"; import React, { ChangeEvent, - InputHTMLAttributes, createContext, + InputHTMLAttributes, useCallback, useContext, useMemo, @@ -20,8 +20,8 @@ import { isComboChartConfig, } from "@/config-types"; import { - GetConfiguratorStateAction, getChartOptionField, + GetConfiguratorStateAction, getFilterValue, isConfiguring, isLayouting, @@ -33,8 +33,8 @@ import { Dimension, DimensionValue, HierarchyValue, - Measure, isMeasure, + Measure, } from "@/domain/data"; import { useLocale } from "@/locales/use-locale"; import { bfs } from "@/utils/bfs"; @@ -451,7 +451,7 @@ export const useAddOrEditChartType = ( // Used in the configurator filters export const useSingleFilterSelect = ( - filters: GetConfiguratorStateAction<"CHART_CONFIG_FILTER_SET_SINGLE">["value"]["filters"] + filters: GetConfiguratorStateAction<"FILTER_SET_SINGLE">["value"]["filters"] ) => { const [state, dispatch] = useConfiguratorState(); const onChange = useCallback< @@ -466,14 +466,14 @@ export const useSingleFilterSelect = ( if (value === FIELD_VALUE_NONE) { dispatch({ - type: "CHART_CONFIG_FILTER_REMOVE_SINGLE", + type: "FILTER_REMOVE_SINGLE", value: { filters, }, }); } else { dispatch({ - type: "CHART_CONFIG_FILTER_SET_SINGLE", + type: "FILTER_SET_SINGLE", value: { filters, value: value === "" ? FIELD_VALUE_NONE : value, @@ -496,6 +496,12 @@ export const useSingleFilterSelect = ( value = get(cube, ["filters", dimensionIri, "value"], FIELD_VALUE_NONE); } } + } else if (isLayouting(state)) { + value = get( + state.dashboardFilters, + ["dataFilters", "filters", filters[0].dimensionIri, "value"], + FIELD_VALUE_NONE + ) as string; } return { @@ -509,14 +515,14 @@ export const useSingleFilterField = ({ filters, value, }: { - filters: GetConfiguratorStateAction<"CHART_CONFIG_FILTER_SET_SINGLE">["value"]["filters"]; + filters: GetConfiguratorStateAction<"FILTER_SET_SINGLE">["value"]["filters"]; value: string; }): FieldProps => { const [state, dispatch] = useConfiguratorState(); const onChange = useCallback<(e: ChangeEvent<HTMLInputElement>) => void>( (e) => { dispatch({ - type: "CHART_CONFIG_FILTER_SET_SINGLE", + type: "FILTER_SET_SINGLE", value: { filters: filters, value: e.currentTarget.value, diff --git a/app/configurator/configurator-state/actions.tsx b/app/configurator/configurator-state/actions.tsx index 61fec710e..db6755326 100644 --- a/app/configurator/configurator-state/actions.tsx +++ b/app/configurator/configurator-state/actions.tsx @@ -139,7 +139,7 @@ export type ConfiguratorStateAction = }; } | { - type: "CHART_CONFIG_FILTER_SET_SINGLE"; + type: "FILTER_SET_SINGLE"; value: { filters: { cubeIri: string; @@ -149,7 +149,7 @@ export type ConfiguratorStateAction = }; } | { - type: "CHART_CONFIG_FILTER_REMOVE_SINGLE"; + type: "FILTER_REMOVE_SINGLE"; value: { filters: { cubeIri: string; @@ -275,4 +275,20 @@ export type ConfiguratorStateAction = } | { type: "DASHBOARD_TIME_RANGE_FILTER_REMOVE"; + } + | { + type: "DASHBOARD_DATA_FILTER_ADD"; + value: { + dimensionIri: string; + }; + } + | { + type: "DASHBOARD_DATA_FILTERS_SET"; + value: DashboardFiltersConfig["dataFilters"]; + } + | { + type: "DASHBOARD_DATA_FILTER_REMOVE"; + value: { + dimensionIri: string; + }; }; diff --git a/app/configurator/configurator-state/context.tsx b/app/configurator/configurator-state/context.tsx index da1cebcca..344c1ba6a 100644 --- a/app/configurator/configurator-state/context.tsx +++ b/app/configurator/configurator-state/context.tsx @@ -1,7 +1,9 @@ +import { ParsedUrlQuery } from "querystring"; + import { PUBLISHED_STATE } from "@prisma/client"; import { NextRouter, useRouter } from "next/router"; import { Dispatch, createContext, useContext, useEffect, useMemo } from "react"; -import { useClient } from "urql"; +import { Client, useClient } from "urql"; import { useImmerReducer } from "use-immer"; import { @@ -155,6 +157,30 @@ const handlePublishSuccess = async ( }); }; +async function initializeChartState( + chartId: string, + query: ParsedUrlQuery, + client: Client, + dataSource: { type: "sql" | "sparql"; url: string }, + locale: string +) { + if (chartId === "new") { + if (query.copy && typeof query.copy === "string") { + return initChartStateFromChartCopy(client, query.copy); + } else if (query.edit && typeof query.edit === "string") { + return initChartStateFromChartEdit( + client, + query.edit, + typeof query.state === "string" ? query.state : undefined + ); + } else if (query.cube && typeof query.cube === "string") { + return initChartStateFromCube(client, query.cube, dataSource, locale); + } + } else if (chartId !== "published") { + return initChartStateFromLocalStorage(client, chartId); + } +} + export async function publishState( user: ReturnType<typeof useUser>, key: string, @@ -260,35 +286,17 @@ const ConfiguratorStateProviderInternal = ( let stateToInitialize = initialStateWithDataSource; const initialize = async () => { try { - let newChartState; - - if (chartId === "new") { - if (query.copy && typeof query.copy === "string") { - newChartState = await initChartStateFromChartCopy( - client, - query.copy - ); - } else if (query.edit && typeof query.edit === "string") { - newChartState = await initChartStateFromChartEdit( - client, - query.edit, - typeof query.state === "string" ? query.state : undefined - ); - } else if (query.cube && typeof query.cube === "string") { - newChartState = await initChartStateFromCube( - client, - query.cube, - dataSource, - locale - ); - } - } else if (chartId !== "published") { - newChartState = await initChartStateFromLocalStorage(client, chartId); - if (!newChartState && allowDefaultRedirect) { - replace(`/create/new`); - } - } + const newChartState = await initializeChartState( + chartId, + query, + client, + dataSource, + locale + ); + if (!newChartState && allowDefaultRedirect && chartId !== "published") { + replace(`/create/new`); + } stateToInitialize = newChartState ?? stateToInitialize; } finally { dispatch({ type: "INITIALIZED", value: stateToInitialize }); diff --git a/app/configurator/configurator-state/initial.tsx b/app/configurator/configurator-state/initial.tsx index a2a060f0d..6a86296e8 100644 --- a/app/configurator/configurator-state/initial.tsx +++ b/app/configurator/configurator-state/initial.tsx @@ -46,6 +46,10 @@ export const getInitialConfiguringConfigBasedOnCube = (props: { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }; }; diff --git a/app/configurator/configurator-state/mocks.ts b/app/configurator/configurator-state/mocks.ts index c3d53a91b..9ac06b778 100644 --- a/app/configurator/configurator-state/mocks.ts +++ b/app/configurator/configurator-state/mocks.ts @@ -70,6 +70,10 @@ export const configStateMock = { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }, groupedColumnChart: { @@ -213,6 +217,10 @@ export const configStateMock = { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }, } satisfies Record<string, ConfiguratorState>; diff --git a/app/configurator/configurator-state/reducer.tsx b/app/configurator/configurator-state/reducer.tsx index 040edce4e..ce745c50c 100644 --- a/app/configurator/configurator-state/reducer.tsx +++ b/app/configurator/configurator-state/reducer.tsx @@ -740,7 +740,7 @@ const reducer_: Reducer<ConfiguratorState, ConfiguratorStateAction> = ( return draft; - case "CHART_CONFIG_FILTER_SET_SINGLE": + case "FILTER_SET_SINGLE": if (isConfiguring(draft)) { const { filters, value } = action.value; const chartConfig = getChartConfig(draft); @@ -765,11 +765,20 @@ const reducer_: Reducer<ConfiguratorState, ConfiguratorStateAction> = ( ); } } + } else if (isLayouting(draft)) { + const { filters, value } = action.value; + const { dimensionIri } = filters[0]; + if (draft.dashboardFilters) { + draft.dashboardFilters.dataFilters.filters[dimensionIri] = { + type: "single", + value, + }; + } } return draft; - case "CHART_CONFIG_FILTER_REMOVE_SINGLE": + case "FILTER_REMOVE_SINGLE": if (isConfiguring(draft)) { const { filters } = action.value; const chartConfig = getChartConfig(draft); @@ -788,6 +797,12 @@ const reducer_: Reducer<ConfiguratorState, ConfiguratorStateAction> = ( chartConfig.interactiveFiltersConfig = newIFConfig; } } + } else if (isLayouting(draft)) { + const { filters } = action.value; + const { dimensionIri } = filters[0]; + if (draft.dashboardFilters) { + delete draft.dashboardFilters.dataFilters.filters[dimensionIri]; + } } return draft; @@ -1085,6 +1100,46 @@ const reducer_: Reducer<ConfiguratorState, ConfiguratorStateAction> = ( } return draft; + case "DASHBOARD_DATA_FILTER_ADD": + if (isLayouting(draft) && draft.dashboardFilters) { + const { dimensionIri } = action.value; + const newFilters = { + ...draft.dashboardFilters, + dataFilters: { + ...draft.dashboardFilters.dataFilters, + componentIris: [ + ...draft.dashboardFilters.dataFilters.componentIris, + dimensionIri, + ], + }, + }; + draft.dashboardFilters = newFilters; + } + return draft; + + case "DASHBOARD_DATA_FILTERS_SET": + if (isLayouting(draft) && draft.dashboardFilters) { + draft.dashboardFilters.dataFilters = action.value; + } + return draft; + + case "DASHBOARD_DATA_FILTER_REMOVE": + if (isLayouting(draft) && draft.dashboardFilters) { + const { dimensionIri } = action.value; + const newFilters = { + ...draft.dashboardFilters, + dataFilters: { + ...draft.dashboardFilters.dataFilters, + componentIris: + draft.dashboardFilters.dataFilters.componentIris.filter( + (d) => d !== dimensionIri + ), + }, + }; + draft.dashboardFilters = newFilters; + } + return draft; + default: throw unreachableError(action); } diff --git a/app/configurator/interactive-filters/time-slider.tsx b/app/configurator/interactive-filters/time-slider.tsx index e2433b5e6..9e9f93465 100644 --- a/app/configurator/interactive-filters/time-slider.tsx +++ b/app/configurator/interactive-filters/time-slider.tsx @@ -16,6 +16,7 @@ import { TableChartState } from "@/charts/table/table-state"; import { Slider as GenericSlider } from "@/components/form"; import { AnimationField, Filters, SortingField } from "@/config-types"; import { parseDate } from "@/configurator/components/ui-helpers"; +import { hasChartConfigs } from "@/configurator/configurator-state"; import { Dimension, isTemporalDimension, @@ -25,10 +26,8 @@ import { import { truthy } from "@/domain/types"; import { useTimeFormatUnit } from "@/formatters"; import { Icon } from "@/icons"; -import { - useChartInteractiveFilters, - useDashboardInteractiveFilters, -} from "@/stores/interactive-filters"; +import { useConfiguratorState } from "@/src"; +import { useChartInteractiveFilters } from "@/stores/interactive-filters"; import { Timeline, TimelineProps } from "@/utils/observables"; import { getSortingOrders, @@ -67,6 +66,7 @@ export const TimeSlider = (props: TimeSliderProps) => { dimensions, } = props; + const [state] = useConfiguratorState(hasChartConfigs); const dimension = useMemo(() => { return dimensions.find((d) => d.iri === componentIri); }, [componentIri, dimensions]); @@ -74,8 +74,6 @@ export const TimeSlider = (props: TimeSliderProps) => { const temporalEntity = isTemporalEntityDimension(dimension); const temporalOrdinal = isTemporalOrdinalDimension(dimension); - const dashboardFilters = useDashboardInteractiveFilters(); - if (!(temporal || temporalEntity || temporalOrdinal)) { throw new Error("You can only use TimeSlider with temporal dimensions!"); } @@ -144,7 +142,7 @@ export const TimeSlider = (props: TimeSliderProps) => { return new Timeline(timelineProps); }, [timelineProps]); - if (dashboardFilters.timeRange?.active) { + if (state.dashboardFilters?.timeRange.active) { return null; } diff --git a/app/docs/charts.stories.tsx b/app/docs/charts.stories.tsx index 87119057f..970c46171 100644 --- a/app/docs/charts.stories.tsx +++ b/app/docs/charts.stories.tsx @@ -77,6 +77,10 @@ const ColumnsStory = { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }} > @@ -136,6 +140,10 @@ const ScatterplotStory = { to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }} > diff --git a/app/docs/fixtures.ts b/app/docs/fixtures.ts index 73b8f5b13..7f80b9883 100644 --- a/app/docs/fixtures.ts +++ b/app/docs/fixtures.ts @@ -97,6 +97,10 @@ export const states: ConfiguratorState[] = [ to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }, ]; diff --git a/app/docs/lines.stories.tsx b/app/docs/lines.stories.tsx index 3c5380ccb..2853da089 100644 --- a/app/docs/lines.stories.tsx +++ b/app/docs/lines.stories.tsx @@ -55,6 +55,10 @@ const LineChartStory = () => ( to: "", }, }, + dataFilters: { + componentIris: [], + filters: {}, + }, }, }} > diff --git a/app/stores/interactive-filters.tsx b/app/stores/interactive-filters.tsx index 3659bcc46..09dd6c1e1 100644 --- a/app/stores/interactive-filters.tsx +++ b/app/stores/interactive-filters.tsx @@ -1,3 +1,4 @@ +import uniq from "lodash/uniq"; import React, { createContext, useContext, useMemo, useRef } from "react"; import create, { StateCreator, StoreApi, UseBoundStore } from "zustand"; @@ -5,17 +6,14 @@ import { getChartSpec } from "@/charts/chart-config-ui-options"; import { CalculationType, ChartConfig, - DashboardTimeRangeFilter, FilterValueSingle, - hasChartConfigs, - useConfiguratorState, } from "@/configurator"; import { truthy } from "@/domain/types"; import { getOriginalIris, isJoinById } from "@/graphql/join"; import { + createBoundUseStoreWithSelector, ExtractState, UseBoundStoreWithSelector, - createBoundUseStoreWithSelector, } from "@/stores/utils"; import { assert } from "@/utils/assert"; @@ -136,16 +134,17 @@ const interactiveFiltersStoreCreator: StateCreator<State> = (set) => { }; }; -type InteractiveFiltersContextValue = [ +export type InteractiveFiltersStore = StoreApi<State>; +export type InteractiveFiltersContextValue = [ UseBoundStore<StoreApi<State>>["getState"], UseBoundStoreWithSelector<StoreApi<State>>, - StoreApi<State>, + InteractiveFiltersStore, ]; const InteractiveFiltersContext = createContext< | { potentialTimeRangeFilterIris: string[]; - timeRange: DashboardTimeRangeFilter | undefined; + potentialDataFilterIris: string[]; stores: Record<ChartConfig["key"], InteractiveFiltersContextValue>; } | undefined @@ -184,6 +183,20 @@ const getPotentialTimeRangeFilterIris = (chartConfigs: ChartConfig[]) => { return temporalDimensions.map((dimension) => dimension.componentIri); }; +const getPotentialDataFilterIris = (chartConfigs: ChartConfig[]) => { + return uniq( + chartConfigs.flatMap((config) => { + return config.cubes + .map((cube) => cube.filters) + .flatMap((filters) => { + return Object.entries(filters) + .filter(([_, filter]) => filter.type === "single") + .map(([dimensionIri]) => dimensionIri); + }); + }) + ); +}; + /** * Creates and provides all the interactive filters stores for the given chartConfigs. */ @@ -193,12 +206,14 @@ export const InteractiveFiltersProvider = ({ }: React.PropsWithChildren<{ chartConfigs: ChartConfig[]; }>) => { - const [state] = useConfiguratorState(hasChartConfigs); const storeRefs = useRef<Record<ChartConfig["key"], StoreApi<State>>>({}); const potentialTimeRangeFilterIris = useMemo(() => { return getPotentialTimeRangeFilterIris(chartConfigs); }, [chartConfigs]); + const potentialDataFilterIris = useMemo(() => { + return getPotentialDataFilterIris(chartConfigs); + }, [chartConfigs]); const stores = useMemo< Record<ChartConfig["key"], InteractiveFiltersContextValue> @@ -218,15 +233,13 @@ export const InteractiveFiltersProvider = ({ ); }, [chartConfigs]); - const timeRange = state.dashboardFilters?.timeRange; - const ctxValue = useMemo( () => ({ - stores, potentialTimeRangeFilterIris, - timeRange, + potentialDataFilterIris, + stores, }), - [stores, potentialTimeRangeFilterIris, timeRange] + [potentialTimeRangeFilterIris, potentialDataFilterIris, stores] ); return ( @@ -303,3 +316,16 @@ export const useDashboardInteractiveFilters = () => { return ctx; }; + +export const setDataFilter = ( + store: InteractiveFiltersStore, + key: string, + value: string +) => { + store.setState({ + dataFilters: { + ...store.getState().dataFilters, + [key]: { type: "single", value }, + }, + }); +}; diff --git a/app/utils/chart-config/versioning.ts b/app/utils/chart-config/versioning.ts index 9aa78f40a..94d24f4ac 100644 --- a/app/utils/chart-config/versioning.ts +++ b/app/utils/chart-config/versioning.ts @@ -931,7 +931,7 @@ export const migrateChartConfig = makeMigrate<ChartConfig>( } ); -export const CONFIGURATOR_STATE_VERSION = "3.5.0"; +export const CONFIGURATOR_STATE_VERSION = "3.6.0"; export const configuratorStateMigrations: Migration[] = [ { @@ -1284,6 +1284,35 @@ export const configuratorStateMigrations: Migration[] = [ return newConfig; }, }, + { + description: "ALL (modify dashboardFilters)", + from: "3.5.0", + to: "3.6.0", + up: (config) => { + const newConfig = { + ...config, + version: "3.6.0", + dashboardFilters: { + ...config.dashboardFilters, + dataFilters: { + componentIris: [], + filters: {}, + }, + }, + }; + return newConfig; + }, + down: (config) => { + const newConfig = { + ...config, + version: "3.5.0", + dashboardFilters: { + timeRange: config.dashboardFilters.timeRange, + }, + }; + return newConfig; + }, + }, ]; export const migrateConfiguratorState = makeMigrate<ConfiguratorState>( diff --git a/app/utils/uniqueMapBy.ts b/app/utils/uniqueMapBy.ts index 39c815a88..16d453721 100644 --- a/app/utils/uniqueMapBy.ts +++ b/app/utils/uniqueMapBy.ts @@ -2,9 +2,7 @@ export const uniqueMapBy = <T, K>(arr: T[], keyFn: (t: T) => K) => { const res = new Map<K, T>(); for (const item of arr) { const key = keyFn(item); - if (res.has(key)) { - console.log(`uniqueMapBy: duplicate detected ${key}, ignoring it`); - } else { + if (!res.has(key)) { res.set(key, item); } }