You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2026/01/14 16:00:25 UTC
(superset) branch master updated: feat(dashboard): chart customizations modal and plugins (#36062)
This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 35ac4c74fd feat(dashboard): chart customizations modal and plugins (#36062)
35ac4c74fd is described below
commit 35ac4c74fdd7d911684d44e8253b986ec065a492
Author: Damian Pendrak <dp...@gmail.com>
AuthorDate: Wed Jan 14 17:00:18 2026 +0100
feat(dashboard): chart customizations modal and plugins (#36062)
---
.../e2e/dashboard/horizontalFilterBar.test.ts | 1 +
.../cypress/e2e/dashboard/nativeFilters.test.ts | 3 +
.../cypress-base/cypress/e2e/dashboard/utils.ts | 10 +-
.../superset-ui-core/src/chart/types/Base.ts | 1 +
.../superset-ui-core/src/query/constants.ts | 1 +
.../packages/superset-ui-core/src/query/index.ts | 1 +
.../superset-ui-core/src/query/types/Dashboard.ts | 81 +-
.../superset-ui-core/src/query/types/Query.ts | 2 +
.../src/query/types/QueryFormData.ts | 5 +-
.../src/DeckGLContainer.tsx | 6 +-
.../legacy-preset-chart-deckgl/src/Multi/Multi.tsx | 127 +-
.../src/Multi/controlPanel.ts | 29 +-
...ckglLayerVisibilityCustomizationPlugin.test.tsx | 484 +++++++
.../DeckglLayerVisibilityCustomizationPlugin.tsx | 204 +++
.../DeckglLayerVisibility/buildQuery.test.ts} | 40 +-
.../DeckglLayerVisibility/buildQuery.ts} | 33 +-
.../DeckglLayerVisibility/controlPanel.ts} | 28 +-
.../components/DeckglLayerVisibility/index.ts} | 40 +-
.../DeckglLayerVisibility/transformProps.test.ts | 100 ++
.../DeckglLayerVisibility/transformProps.ts} | 33 +-
.../components/DeckglLayerVisibility}/types.ts | 39 +-
.../useDeckLayerMetadata.test.ts | 195 +++
.../DeckglLayerVisibility/useDeckLayerMetadata.ts | 87 ++
.../DynamicGroupBy/DynamicGroupByPlugin.tsx | 140 ++
.../components/DynamicGroupBy/buildQuery.ts} | 33 +-
.../components/DynamicGroupBy/controlPanel.ts | 80 ++
.../components/DynamicGroupBy/images/thumbnail.png | Bin 0 -> 5658 bytes
.../components/DynamicGroupBy/index.ts} | 41 +-
.../components/DynamicGroupBy/transformProps.ts} | 50 +-
.../components/DynamicGroupBy}/types.ts | 74 +-
.../TimeColumn/TimeColumnFilterPlugin.tsx | 127 ++
.../components/TimeColumn/buildQuery.ts | 44 +
.../components/TimeColumn/controlPanel.ts} | 41 +-
.../components/TimeColumn/images/thumbnail.png | Bin 0 -> 5658 bytes
.../components/TimeColumn/index.ts} | 40 +-
.../components/TimeColumn/transformProps.ts | 60 +
.../components/TimeColumn/types.ts | 48 +
.../components/TimeGrain/TimeGrainFilterPlugin.tsx | 159 +++
.../components/TimeGrain/buildQuery.ts | 44 +
.../components/TimeGrain/controlPanel.ts} | 41 +-
.../components/TimeGrain/images/thumbnail.png | Bin 0 -> 5658 bytes
.../components/TimeGrain/index.ts} | 40 +-
.../components/TimeGrain/transformProps.ts} | 50 +-
.../components/TimeGrain/types.ts | 42 +
.../src/chartCustomizations/components/common.ts | 57 +
.../components/index.ts} | 28 +-
.../components}/types.ts | 34 +-
superset-frontend/src/constants.ts | 7 +
.../dashboard/actions/chartCustomizationActions.ts | 378 +++--
superset-frontend/src/dashboard/actions/hydrate.js | 9 +-
.../src/dashboard/actions/nativeFilters.ts | 23 +-
.../index.tsx | 206 +--
.../DashboardBuilder/DashboardContainer.test.tsx | 211 +--
.../DashboardBuilder/DashboardContainer.tsx | 152 +-
.../dashboard/components/DashboardBuilder/state.ts | 27 +-
.../src/dashboard/components/SliceHeader/index.tsx | 4 +-
.../components/SyncDashboardState/index.tsx | 5 +-
.../components/gridComponents/Chart/Chart.jsx | 6 +-
.../ChartCustomization/ChartCustomizationForm.tsx | 1457 --------------------
.../ChartCustomization/ChartCustomizationModal.tsx | 700 ----------
.../ChartCustomizationTitleContainer.tsx | 184 ---
.../ChartCustomizationTitlePane.tsx | 124 --
.../ChartCustomization/groupBySelectors.ts | 262 ----
.../nativeFilters/ChartCustomization/selectors.ts | 47 -
.../useChartCustomizationModal.tsx | 52 -
.../nativeFilters/ChartCustomization/utils.ts | 63 -
.../nativeFilters/ConfigModal/SharedStyles.tsx | 1 -
.../FilterBar/ActionButtons/index.tsx | 15 +-
.../CustomizationsOutOfScopeCollapsible/index.tsx | 67 +
.../nativeFilters/FilterBar/FilterBar.test.tsx | 45 +-
.../FilterBar/FilterBarSettings/index.tsx | 29 +-
.../useFilterConfigModal.tsx | 33 +-
.../FilterBar/FilterControls/FilterControl.tsx | 280 +---
.../{FilterControl.tsx => FilterControlShared.tsx} | 244 +---
.../FilterControls/FilterControls.test.tsx | 186 +++
.../FilterBar/FilterControls/FilterControls.tsx | 206 ++-
.../FilterBar/FilterControls/FilterValue.tsx | 72 +-
.../FilterControls}/GroupByFilterCard.tsx | 403 +++---
.../FilterBar/FilterControls/types.ts | 19 +-
.../nativeFilters/FilterBar/Horizontal.tsx | 15 +-
.../FilterBar/HorizontalFilterBar.test.tsx | 2 +
.../nativeFilters/FilterBar/Vertical.tsx | 23 +-
.../components/nativeFilters/FilterBar/index.tsx | 226 ++-
.../components/nativeFilters/FilterBar/state.ts | 67 +-
.../components/nativeFilters/FilterBar/types.ts | 7 +
.../FilterBar/useFilterControlFactory.tsx | 5 +-
.../components/nativeFilters/FilterBar/utils.ts | 10 +-
.../nativeFilters/FilterCard/FilterCard.test.tsx | 8 +-
.../nativeFilters/FilterCard/FilterCardContent.tsx | 4 +-
.../nativeFilters/FilterCard/NameRow.tsx | 25 +-
.../nativeFilters/FilterCard/TypeRow.tsx | 8 +-
.../components/nativeFilters/FilterCard/types.ts | 5 +-
.../FilterCard/useFilterDependencies.ts | 5 +-
.../nativeFilters/FilterCard/useFilterScope.ts | 4 +-
.../ConfigModalContent/ConfigModalContent.tsx | 164 +++
.../CustomizationContentRenderer.tsx | 133 ++
.../ConfigModalContent/FilterContentRenderer.tsx | 136 ++
.../ConfigModalContent/index.ts} | 31 +-
.../ConfigModalSidebar/ConfigModalSidebar.tsx | 192 +++
.../ConfigModalSidebar/ItemSection.tsx | 77 ++
.../ConfigModalSidebar/index.ts} | 29 +-
.../FiltersConfigModal/DividerConfigForm.tsx | 64 +-
.../FiltersConfigModal/DraggableFilter.test.tsx | 230 +++
.../FiltersConfigModal/DraggableFilter.tsx | 74 +-
.../FiltersConfigForm/DatasetSelect.tsx | 4 +-
.../FiltersConfigForm/DefaultValue.tsx | 20 +-
.../FiltersConfigForm/FiltersConfigForm.tsx | 371 +++--
.../FiltersConfigForm/constants.ts | 11 +
.../FiltersConfigForm/getControlItemsMap.tsx | 14 +-
.../FiltersConfigModal/FiltersConfigForm/state.ts | 16 +-
.../FiltersConfigModal/FiltersConfigModal.test.tsx | 149 +-
.../FiltersConfigModal/FiltersConfigModal.tsx | 956 ++++++-------
.../FiltersConfigModal/ItemTitleContainer.tsx | 194 +++
.../FiltersConfigModal/ItemTitlePane.tsx | 93 ++
.../FiltersConfigModal/NativeFiltersModal.test.tsx | 9 +-
.../FiltersConfigModal/NewItemDropdown.tsx | 93 ++
.../types.ts => FiltersConfigModal/hooks/index.ts} | 39 +-
.../hooks/useCustomizationOperations.ts | 127 ++
.../hooks/useFilterOperations.ts | 248 ++++
.../hooks/useItemStateManager.ts | 154 +++
.../FiltersConfigModal/hooks/useModalSaveLogic.ts | 430 ++++++
.../nativeFilters/FiltersConfigModal/state.ts | 16 +-
.../transformers/customizationTransformer.ts | 163 +++
.../transformers/filterTransformer.ts | 172 +++
.../transformers/index.ts} | 27 +-
.../nativeFilters/FiltersConfigModal/types.ts | 80 +-
.../nativeFilters/FiltersConfigModal/utils.test.ts | 139 ++
.../nativeFilters/FiltersConfigModal/utils.ts | 160 ++-
.../dashboard/components/nativeFilters/state.ts | 191 ++-
.../dashboard/components/nativeFilters/utils.ts | 31 +-
.../src/dashboard/reducers/dashboardInfo.js | 101 +-
.../dashboard/reducers/groupByCustomizations.ts | 198 ---
.../src/dashboard/reducers/nativeFilters.test.ts | 77 ++
.../src/dashboard/reducers/nativeFilters.ts | 51 +-
superset-frontend/src/dashboard/types.ts | 20 +-
.../dashboard/util/activeAllDashboardFilters.ts | 5 +-
.../src/dashboard/util/calculateScopes.ts | 75 +
.../util/charts/getFormDataWithExtraFilters.ts | 119 +-
.../util/getFormDataWithExtraFilters.test.ts | 114 ++
.../src/dashboard/util/getRelatedCharts.ts | 13 +-
.../util/useFilterFocusHighlightStyles.test.tsx | 4 +
.../util/useFilterFocusHighlightStyles.ts | 10 +-
superset-frontend/src/dataMask/actions.ts | 3 +
superset-frontend/src/dataMask/reducer.ts | 160 ++-
.../controls/SelectAsyncControl/index.tsx | 18 +-
superset-frontend/src/views/store.ts | 2 -
.../src/visualizations/presets/MainPreset.js | 20 +-
superset/commands/dashboard/exceptions.py | 4 +
superset/commands/dashboard/update.py | 18 +
superset/daos/dashboard.py | 64 +
superset/dashboards/api.py | 91 ++
superset/dashboards/schemas.py | 6 +
tests/integration_tests/dashboards/api_tests.py | 325 +++++
.../dashboards/test_chart_customizations_dao.py | 339 +++++
154 files changed, 9604 insertions(+), 6024 deletions(-)
diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts
index 17fbf587b7..f5d71d753b 100644
--- a/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/horizontalFilterBar.test.ts
@@ -96,6 +96,7 @@ describe('Horizontal FilterBar', () => {
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
+ cy.get('.ant-dropdown-menu').should('be.visible');
cy.getBySel('filter-bar__create-filter').should('exist');
cy.getBySel('filterbar-action-buttons').should('exist');
});
diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.test.ts
index d70a370139..c2e8eaf12d 100644
--- a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.test.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.test.ts
@@ -378,6 +378,7 @@ describe('Native filters', () => {
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
+ cy.get('.ant-dropdown-menu').should('be.visible');
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).should(
'be.visible',
);
@@ -405,6 +406,8 @@ describe('Native filters', () => {
it('Verify setting options and tooltips for value filter', () => {
enterNativeFilterEditModal(false);
cy.contains('Filter value is required').scrollIntoView();
+ cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
+ cy.wait(300);
cy.contains('Filter value is required').should('be.visible').click({
force: true,
diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
index 8ed39621ba..0822523cff 100644
--- a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
@@ -237,6 +237,7 @@ export function enterNativeFilterEditModal(waitForDataset = true) {
cy.get(nativeFilters.filtersPanel.filterGear).click({
force: true,
});
+ cy.get('.ant-dropdown-menu').should('be.visible');
cy.get(nativeFilters.filterFromDashboardView.createFilterButton).click({
force: true,
});
@@ -252,7 +253,9 @@ export function enterNativeFilterEditModal(waitForDataset = true) {
* @summary helper for adding new filter
************************************************************************* */
export function clickOnAddFilterInModal() {
- return cy.get(nativeFilters.modal.addNewFilterButton).click({ force: true });
+ cy.get('[data-test="new-item-dropdown-button"]').trigger('mouseover');
+ cy.get('.ant-dropdown-menu').should('be.visible');
+ cy.contains('.ant-dropdown-menu-item', 'Add filter').click();
}
/** ************************************************************************
@@ -459,10 +462,13 @@ export function checkNativeFilterTooltip(index: number, value: string) {
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
.eq(index)
.trigger('mouseover');
- cy.contains(`${value}`);
+ cy.contains(`${value}`).should('be.visible');
+ cy.wait(100);
cy.get(nativeFilters.filterConfigurationSections.infoTooltip)
.eq(index)
.trigger('mouseout');
+ cy.get('body').trigger('mousemove', { clientX: 0, clientY: 0 });
+ cy.wait(500);
}
/** ************************************************************************
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
index f438593dca..15459d1114 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/types/Base.ts
@@ -25,6 +25,7 @@ export type HandlerFunction = (...args: unknown[]) => void;
export enum Behavior {
InteractiveChart = 'INTERACTIVE_CHART',
NativeFilter = 'NATIVE_FILTER',
+ ChartCustomization = 'CHART_CUSTOMIZATION',
/**
* Include `DRILL_TO_DETAIL` behavior if plugin handles `contextmenu` event
diff --git a/superset-frontend/packages/superset-ui-core/src/query/constants.ts b/superset-frontend/packages/superset-ui-core/src/query/constants.ts
index 0c2df20d3a..3d91620a1b 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/constants.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/constants.ts
@@ -49,6 +49,7 @@ export const EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS: Record<
time_grain: 'time_grain',
time_range: 'time_range',
time_compare: 'time_compare',
+ visible_deckgl_layers: 'visible_deckgl_layers',
};
export const EXTRA_FORM_DATA_OVERRIDE_REGULAR_KEYS = Object.keys(
diff --git a/superset-frontend/packages/superset-ui-core/src/query/index.ts b/superset-frontend/packages/superset-ui-core/src/query/index.ts
index 434fe95798..69ecc0cb92 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/index.ts
@@ -39,6 +39,7 @@ export * from './types/Column';
export * from './types/Datasource';
export * from './types/Metric';
export * from './types/Query';
+export * from './types/Dashboard';
export * from './api/v1/types';
export { default as makeApi } from './api/v1/makeApi';
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
index ac4b19cae5..a2eb7979c4 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
@@ -19,6 +19,11 @@
import { AdhocFilter, DataMask } from '@superset-ui/core';
+export interface ColumnOption {
+ label: string;
+ value: string;
+}
+
export interface NativeFilterColumn {
name: string;
displayName?: string;
@@ -44,6 +49,11 @@ export enum NativeFilterType {
Divider = 'DIVIDER',
}
+export enum ChartCustomizationType {
+ ChartCustomization = 'CHART_CUSTOMIZATION',
+ Divider = 'CHART_CUSTOMIZATION_DIVIDER',
+}
+
export enum DataMaskType {
NativeFilters = 'nativeFilters',
CrossFilters = 'crossFilters',
@@ -61,9 +71,7 @@ export type Filter = {
name: string;
scope: NativeFilterScope;
filterType: string;
- // for now there will only ever be one target
- // when multiple targets are supported, change this to Target[]
- targets: [Partial<NativeFilterTarget>];
+ targets: Partial<NativeFilterTarget>[];
controlValues: {
[key: string]: any;
};
@@ -80,6 +88,35 @@ export type Filter = {
description: string;
};
+export type ChartCustomization = {
+ id: string;
+ type: typeof ChartCustomizationType.ChartCustomization;
+ name: string;
+ filterType: string;
+ targets: Partial<NativeFilterTarget>[];
+ scope: NativeFilterScope;
+ chartsInScope?: number[];
+ tabsInScope?: string[];
+ cascadeParentIds?: string[];
+ defaultDataMask: DataMask;
+ controlValues: {
+ sortAscending?: boolean;
+ sortMetric?: string;
+ [key: string]: any;
+ };
+ description?: string;
+ removed?: boolean;
+};
+
+export type ChartCustomizationDivider = Partial<
+ Omit<ChartCustomization, 'id' | 'type'>
+> & {
+ id: string;
+ title: string;
+ description: string;
+ type: typeof ChartCustomizationType.Divider;
+};
+
export type AppliedFilter = {
values: {
filters: Record<string, any>[];
@@ -146,10 +183,30 @@ export function isFilterDivider(
return filterElement.type === NativeFilterType.Divider;
}
+export function isChartCustomization(
+ filterElement:
+ | Filter
+ | Divider
+ | ChartCustomization
+ | ChartCustomizationDivider,
+): filterElement is ChartCustomization {
+ return filterElement.type === ChartCustomizationType.ChartCustomization;
+}
+
+export function isChartCustomizationDivider(
+ filterElement: ChartCustomization | ChartCustomizationDivider,
+): filterElement is ChartCustomizationDivider {
+ return filterElement.type === ChartCustomizationType.Divider;
+}
+
export type FilterConfiguration = Array<Filter | Divider>;
export type Filters = {
- [filterId: string]: Filter | Divider;
+ [filterId: string]:
+ | Filter
+ | Divider
+ | ChartCustomization
+ | ChartCustomizationDivider;
};
export type PartialFilters = {
@@ -162,6 +219,22 @@ export type NativeFiltersState = {
hoveredFilterId?: string;
};
+export type ChartCustomizationConfiguration = Array<
+ ChartCustomization | ChartCustomizationDivider
+>;
+
+export type ChartCustomizations = {
+ [chartCustomizationId: string]:
+ | ChartCustomization
+ | ChartCustomizationDivider;
+};
+
+export type PartialChartCustomizations = {
+ [chartCustomizationId: string]: Partial<
+ ChartCustomizations[keyof ChartCustomizations]
+ >;
+};
+
export type DashboardComponentMetadata = {
nativeFilters: NativeFiltersState;
dataMask: DataMaskStateWithId;
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index 9a8033a9c6..fcbfd3ed7a 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -153,6 +153,8 @@ export interface QueryObject
series_columns?: QueryFormColumn[];
series_limit?: number;
series_limit_metric?: Maybe<QueryFormMetric>;
+
+ visible_deckgl_layers?: number[];
}
export interface QueryContext {
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/QueryFormData.ts b/superset-frontend/packages/superset-ui-core/src/query/types/QueryFormData.ts
index 3409c61fcc..a3de0fb3b9 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/QueryFormData.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/QueryFormData.ts
@@ -131,7 +131,10 @@ export type ExtraFormDataOverrideRegular = Partial<
> &
Partial<Pick<SqlaFormData, 'granularity'>> &
Partial<Pick<BaseFormData, 'time_range'>> &
- Partial<Pick<QueryObject, 'time_column' | 'time_grain' | 'time_compare'>>;
+ Partial<Pick<QueryObject, 'time_column' | 'time_grain' | 'time_compare'>> & {
+ /** deck.gl layer visibility filter - controls which layers are visible in deck.gl multi-layer charts */
+ visible_deckgl_layers?: number[];
+ };
/** These parameters override those already present in the form data/query object */
export type ExtraFormDataOverride = ExtraFormDataOverrideRegular &
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx
index 7a929d506f..b22de70878 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx
@@ -38,6 +38,7 @@ import DeckGL from '@deck.gl/react';
import type { Layer } from '@deck.gl/core';
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
+import { Device } from '@luma.gl/core';
import Tooltip, { TooltipProps } from './components/Tooltip';
import 'mapbox-gl/dist/mapbox-gl.css';
import { Viewport } from './utils/fitViewport';
@@ -168,7 +169,10 @@ export const DeckGLContainer = memo(
layers={layers()}
viewState={viewState}
onViewStateChange={onViewStateChange}
- onAfterRender={(context: any) => {
+ onAfterRender={(context: {
+ device: Device;
+ gl: WebGL2RenderingContext;
+ }) => {
glContextRef.current = context.gl;
}}
>
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx
index afdfa07dbc..c6d2b48e32 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx
@@ -19,14 +19,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
import { isEqual } from 'lodash';
+import { createSelector } from '@reduxjs/toolkit';
import {
AdhocFilter,
ContextMenuFilters,
DataMask,
Datasource,
ensureIsArray,
+ ExtraFormData,
FilterState,
HandlerFunction,
isDefined,
@@ -37,6 +40,7 @@ import {
SupersetClient,
usePrevious,
} from '@superset-ui/core';
+import { styled } from '@apache-superset/core/ui';
import { Layer } from '@deck.gl/core';
import {
@@ -59,6 +63,13 @@ import { getPoints as getPointsHex } from '../layers/Hex/Hex';
import { getPoints as getPointsGeojson } from '../layers/Geojson/Geojson';
import { getPoints as getPointsScreengrid } from '../layers/Screengrid/Screengrid';
+type DataMaskState = Record<
+ string,
+ DataMask & {
+ extraFormData?: ExtraFormData & { visible_deckgl_layers?: number[] };
+ }
+>;
+
export type DeckMultiProps = {
formData: QueryFormData;
payload: JsonObject;
@@ -79,9 +90,29 @@ export type DeckMultiProps = {
emitCrossFilters?: boolean;
};
+const MultiWrapper = styled.div<{ height: number; width: number }>`
+ position: relative;
+ height: ${({ height }) => height}px;
+ width: ${({ width }) => width}px;
+`;
+
+const selectDataMask = createSelector(
+ (state: { dataMask?: DataMaskState }) => state.dataMask,
+ dataMask => dataMask || {},
+);
+
const DeckMulti = (props: DeckMultiProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
+ const dataMask = useSelector(selectDataMask);
+
+ const layerVisibilityFilter = Object.values(dataMask).find(
+ mask => mask?.extraFormData?.visible_deckgl_layers !== undefined,
+ );
+
+ const visibleDeckLayersFromRedux =
+ layerVisibilityFilter?.extraFormData?.visible_deckgl_layers;
+
const getAdjustedViewport = useCallback(() => {
let viewport = { ...props.viewport };
const points = [
@@ -114,6 +145,7 @@ const DeckMulti = (props: DeckMultiProps) => {
const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>(
{},
);
+ const [layerOrder, setLayerOrder] = useState<number[]>([]);
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
const { current } = containerRef;
@@ -263,9 +295,8 @@ const DeckMulti = (props: DeckMultiProps) => {
}));
})
.catch(error => {
- console.error(
- `Error loading layer for slice ${subsliceCopy.slice_id}:`,
- error,
+ throw new Error(
+ `Error loading layer for slice ${subsliceCopy.slice_id}: ${error}`,
);
});
}
@@ -277,44 +308,98 @@ const DeckMulti = (props: DeckMultiProps) => {
(
formData: QueryFormData,
payload: JsonObject,
- viewport?: Viewport,
+ visibleLayers?: number[],
): void => {
setViewport(getAdjustedViewport());
setSubSlicesLayers({});
+ let visibleDeckLayers = visibleLayers;
+
+ if (!visibleDeckLayers) {
+ visibleDeckLayers = (
+ formData.extra_form_data as ExtraFormData & {
+ visible_deckgl_layers?: number[];
+ }
+ )?.visible_deckgl_layers;
+ }
+
+ const deckSlicesOrder = formData.deck_slices || [];
+
payload.data.slices.forEach(
(subslice: { slice_id: number } & JsonObject, payloadIndex: number) => {
+ if (visibleDeckLayers && Array.isArray(visibleDeckLayers)) {
+ if (!visibleDeckLayers.includes(subslice.slice_id)) {
+ return;
+ }
+ }
+
loadSingleLayer(subslice, formData, payloadIndex);
},
);
+
+ const orderedSliceIds = deckSlicesOrder.filter((sliceId: number) => {
+ const subslice = payload.data.slices.find(
+ (s: { slice_id: number }) => s.slice_id === sliceId,
+ );
+ if (!subslice) return false;
+ if (visibleDeckLayers && Array.isArray(visibleDeckLayers)) {
+ return visibleDeckLayers.includes(sliceId);
+ }
+ return true;
+ });
+
+ setLayerOrder(orderedSliceIds);
},
[getAdjustedViewport, loadSingleLayer],
);
const prevDeckSlices = usePrevious(props.formData.deck_slices);
+ const prevVisibleLayersRedux = usePrevious(visibleDeckLayersFromRedux);
+
useEffect(() => {
const { formData, payload } = props;
- const hasChanges = !isEqual(prevDeckSlices, formData.deck_slices);
- if (hasChanges) {
- loadLayers(formData, payload);
+
+ const deckSlicesChanged = !isEqual(prevDeckSlices, formData.deck_slices);
+ const visibilityFilterChanged = !isEqual(
+ prevVisibleLayersRedux,
+ visibleDeckLayersFromRedux,
+ );
+
+ if (deckSlicesChanged || visibilityFilterChanged) {
+ loadLayers(formData, payload, undefined);
}
- }, [loadLayers, prevDeckSlices, props]);
+ }, [
+ loadLayers,
+ prevDeckSlices,
+ prevVisibleLayersRedux,
+ visibleDeckLayersFromRedux,
+ props,
+ ]);
const { payload, formData, setControlValue, height, width } = props;
- const layers = Object.values(subSlicesLayers);
+
+ const layers = useMemo(
+ () =>
+ layerOrder
+ .map(sliceId => subSlicesLayers[sliceId])
+ .filter(layer => layer !== undefined),
+ [layerOrder, subSlicesLayers],
+ );
return (
- <DeckGLContainerStyledWrapper
- ref={containerRef}
- mapboxApiAccessToken={payload.data.mapboxApiKey}
- viewport={viewport}
- layers={layers}
- mapStyle={formData.mapbox_style}
- setControlValue={setControlValue}
- onViewportChange={setViewport}
- height={height}
- width={width}
- />
+ <MultiWrapper height={height} width={width}>
+ <DeckGLContainerStyledWrapper
+ ref={containerRef}
+ mapboxApiAccessToken={payload.data.mapboxApiKey}
+ viewport={viewport}
+ layers={layers}
+ mapStyle={formData.mapbox_style}
+ setControlValue={setControlValue}
+ onViewportChange={setViewport}
+ height={height}
+ width={width}
+ />
+ </MultiWrapper>
);
};
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts
index eaf990e07a..1ad60394d7 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts
@@ -34,26 +34,37 @@ export default {
config: {
type: 'SelectAsyncControl',
multi: true,
- label: t('deck.gl charts'),
+ label: t('deck.gl layers (charts)'),
validators: [validateNonEmpty],
default: [],
description: t(
- 'Pick a set of deck.gl charts to layer on top of one another',
+ 'Select layers in the order you want them stacked. First selected appears at the bottom.Layers let you combine multiple visualizations on one map. Each layer is a saved deck.gl chart (like scatter plots, polygons, or arcs) that displays different data or insights. Stack them to reveal patterns and relationships across your data.',
),
dataEndpoint:
'api/v1/chart/?q=(filters:!((col:viz_type,opr:sw,value:deck)))',
placeholder: t('Select charts'),
onAsyncErrorMessage: t('Error while fetching charts'),
- mutator: (data: {
- result?: { id: number; slice_name: string }[];
- }) => {
+ mutator: (
+ data: {
+ result?: { id: number; slice_name: string }[];
+ },
+ value: number[] | undefined,
+ ) => {
if (!data?.result) {
return [];
}
- return data.result.map(o => ({
- value: o.id,
- label: o.slice_name,
- }));
+ const selectedIds = Array.isArray(value) ? value : [];
+
+ return data.result.map(o => {
+ const selectedIndex = selectedIds.indexOf(o.id);
+ const indexLabel =
+ selectedIndex !== -1 ? ` [${selectedIndex + 1}]` : '';
+
+ return {
+ value: o.id,
+ label: `${o.slice_name}${indexLabel}`,
+ };
+ });
},
},
},
diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx
new file mode 100644
index 0000000000..f3ef1d3389
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx
@@ -0,0 +1,484 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { SupersetClient } from '@superset-ui/core';
+import DeckglLayerVisibilityCustomizationPlugin from './DeckglLayerVisibilityCustomizationPlugin';
+import { PluginDeckglLayerVisibilityProps } from './types';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ SupersetClient: {
+ get: jest.fn(),
+ },
+}));
+
+const mockSupersetClientGet = SupersetClient.get as jest.Mock;
+
+const defaultProps: PluginDeckglLayerVisibilityProps = {
+ formData: {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ },
+ height: 400,
+ width: 600,
+ filterState: {},
+ setDataMask: jest.fn(),
+};
+
+const mockCharts = {
+ chart1: {
+ form_data: {
+ viz_type: 'deck_multi',
+ deck_slices: [1, 2, 3],
+ },
+ },
+ chart2: {
+ form_data: {
+ viz_type: 'deck_multi',
+ deck_slices: [4, 5],
+ },
+ },
+ chart3: {
+ form_data: {
+ viz_type: 'line',
+ },
+ },
+};
+
+const mockApiResponse = {
+ json: {
+ result: [
+ { id: 1, slice_name: 'Scatter Layer', viz_type: 'deck_scatter' },
+ { id: 2, slice_name: 'Arc Layer', viz_type: 'deck_arc' },
+ { id: 3, slice_name: 'Path Layer', viz_type: 'deck_path' },
+ { id: 4, slice_name: 'Hex Layer', viz_type: 'deck_hex' },
+ { id: 5, slice_name: 'Grid Layer', viz_type: 'deck_grid' },
+ ],
+ },
+};
+
+test('displays loading state initially', () => {
+ mockSupersetClientGet.mockImplementation(() => new Promise(() => {}));
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ });
+
+ expect(screen.getByText('Loading deck.gl layers...')).toBeInTheDocument();
+});
+
+test('displays message when no deck.gl multi layer charts are found', async () => {
+ mockSupersetClientGet.mockResolvedValue({ json: { result: [] } });
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: {
+ slices: {
+ chart1: {
+ form_data: {
+ viz_type: 'line',
+ },
+ },
+ },
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'No deck.gl multi layer charts found in this dashboard.',
+ ),
+ ).toBeInTheDocument();
+ });
+});
+
+test('renders layer selection control with layers from API', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+});
+
+test('collects unique layer IDs from multiple deck_multi charts', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ });
+
+ await waitFor(() => {
+ expect(mockSupersetClientGet).toHaveBeenCalled();
+ });
+
+ const callArgs = mockSupersetClientGet.mock.calls[0][0];
+ expect(callArgs.endpoint).toContain('/api/v1/chart/?q=');
+});
+
+test('handles layer selection and calls setDataMask', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ setDataMask={setDataMaskMock}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole('combobox');
+ await userEvent.click(select);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Scatter Layer (deck_scatter)'),
+ ).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByText('Scatter Layer (deck_scatter)'));
+
+ await waitFor(() => {
+ expect(setDataMaskMock).toHaveBeenCalledWith({
+ filterState: {
+ value: [1],
+ },
+ extraFormData: {
+ visible_deckgl_layers: [2, 3, 4, 5],
+ },
+ });
+ });
+});
+
+test('initializes with filterState value when provided', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ filterState={{ value: [1, 2] }}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole('combobox');
+ await userEvent.click(select);
+
+ await waitFor(() => {
+ const selectedItems = screen.getAllByRole('option', { selected: true });
+ expect(selectedItems).toHaveLength(2);
+ });
+});
+
+test('initializes all layers visible when defaultToAllLayersVisible is true and no prior state', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ formData={{
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ }}
+ setDataMask={setDataMaskMock}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(setDataMaskMock).toHaveBeenCalledWith({
+ filterState: {
+ value: [],
+ },
+ extraFormData: {
+ visible_deckgl_layers: [1, 2, 3, 4, 5],
+ },
+ });
+ });
+});
+
+test('does not auto-initialize when defaultToAllLayersVisible is false', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ formData={{
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: false,
+ datasource: '1__table',
+ }}
+ setDataMask={setDataMaskMock}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+});
+
+test('handles multiple layer selection', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ setDataMask={setDataMaskMock}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ const select = screen.getByRole('combobox');
+ await userEvent.click(select);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Scatter Layer (deck_scatter)'),
+ ).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByText('Scatter Layer (deck_scatter)'));
+ await userEvent.click(screen.getByText('Arc Layer (deck_arc)'));
+
+ await waitFor(() => {
+ expect(setDataMaskMock).toHaveBeenLastCalledWith({
+ filterState: {
+ value: expect.arrayContaining([1, 2]),
+ },
+ extraFormData: {
+ visible_deckgl_layers: expect.arrayContaining([3, 4, 5]),
+ },
+ });
+ });
+});
+
+test('displays tooltip info icon', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ const tooltipIcon = screen.getByRole('img', { name: /info-circle/i });
+ expect(tooltipIcon).toBeInTheDocument();
+
+ await userEvent.hover(tooltipIcon);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'Choose layers to hide from all deck.gl Multiple Layer charts in this dashboard.',
+ ),
+ ).toBeInTheDocument();
+ });
+});
+
+test('handles charts with undefined deck_slices', async () => {
+ mockSupersetClientGet.mockResolvedValue({ json: { result: [] } });
+
+ const chartsWithUndefined = {
+ chart1: {
+ form_data: {
+ viz_type: 'deck_multi',
+ },
+ },
+ };
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: chartsWithUndefined },
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'No deck.gl multi layer charts found in this dashboard.',
+ ),
+ ).toBeInTheDocument();
+ });
+});
+
+test('handles charts with non-array deck_slices', async () => {
+ mockSupersetClientGet.mockResolvedValue({ json: { result: [] } });
+
+ const chartsWithInvalidSlices = {
+ chart1: {
+ form_data: {
+ viz_type: 'deck_multi',
+ deck_slices: 'invalid',
+ },
+ },
+ };
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: chartsWithInvalidSlices },
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'No deck.gl multi layer charts found in this dashboard.',
+ ),
+ ).toBeInTheDocument();
+ });
+});
+
+test('deduplicates layer IDs from multiple charts', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+
+ const chartsWithDuplicates = {
+ chart1: {
+ form_data: {
+ viz_type: 'deck_multi',
+ deck_slices: [1, 2, 3],
+ },
+ },
+ chart2: {
+ form_data: {
+ viz_type: 'deck_multi',
+ deck_slices: [2, 3, 4],
+ },
+ },
+ };
+
+ render(<DeckglLayerVisibilityCustomizationPlugin {...defaultProps} />, {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: chartsWithDuplicates },
+ },
+ });
+
+ await waitFor(() => {
+ expect(mockSupersetClientGet).toHaveBeenCalled();
+ });
+
+ const callArgs = mockSupersetClientGet.mock.calls[0][0];
+ expect(callArgs.endpoint).toContain('/api/v1/chart/?q=');
+});
+
+test('respects existing visible_deckgl_layers from Redux state', async () => {
+ mockSupersetClientGet.mockResolvedValue(mockApiResponse);
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <DeckglLayerVisibilityCustomizationPlugin
+ {...defaultProps}
+ formData={{
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ }}
+ setDataMask={setDataMaskMock}
+ />,
+ {
+ useRedux: true,
+ initialState: {
+ sliceEntities: { slices: mockCharts },
+ dataMask: {
+ filter1: {
+ extraFormData: {
+ visible_deckgl_layers: [1, 2],
+ },
+ },
+ },
+ },
+ },
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Exclude layers (deck.gl)')).toBeInTheDocument();
+ });
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx
new file mode 100644
index 0000000000..dca9832dd6
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx
@@ -0,0 +1,204 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
+import { t } from '@apache-superset/core';
+import { DataMask, ExtraFormData } from '@superset-ui/core';
+import { useTheme } from '@apache-superset/core/ui';
+import {
+ Select,
+ FormItem,
+ Tooltip,
+ Icons,
+ Flex,
+} from '@superset-ui/core/components';
+import { useSelector } from 'react-redux';
+import { createSelector } from '@reduxjs/toolkit';
+import { PluginDeckglLayerVisibilityProps } from './types';
+import { useDeckLayerMetadata } from './useDeckLayerMetadata';
+import { FilterPluginStyle } from '../common';
+import { Slice } from 'src/dashboard/types';
+
+type SliceEntitiesState = {
+ sliceEntities?: {
+ slices: Record<number, Slice>;
+ };
+};
+
+type DataMaskState = Record<
+ string,
+ DataMask & {
+ extraFormData?: ExtraFormData & { visible_deckgl_layers?: number[] };
+ }
+>;
+
+const EMPTY_OBJECT = {};
+
+const selectAllLayerIds = createSelector(
+ [
+ (state: SliceEntitiesState) =>
+ state.sliceEntities?.slices || (EMPTY_OBJECT as Record<number, Slice>),
+ ],
+ slices => {
+ const ids: number[] = [];
+ Object.values(slices).forEach(slice => {
+ if (slice.form_data?.viz_type === 'deck_multi') {
+ const deckSlices = slice.form_data.deck_slices as number[] | undefined;
+ if (deckSlices && Array.isArray(deckSlices)) {
+ ids.push(...deckSlices);
+ }
+ }
+ });
+ return [...new Set(ids)];
+ },
+);
+
+export default function DeckglLayerVisibilityCustomizationPlugin(
+ props: PluginDeckglLayerVisibilityProps,
+) {
+ const { formData, filterState, setDataMask, width, height } = props;
+ const theme = useTheme();
+ const [hiddenLayers, setHiddenLayers] = useState<number[]>(
+ filterState?.value || [],
+ );
+ const hasInitialized = useRef(false);
+
+ const allLayerIds = useSelector(selectAllLayerIds);
+ const dataMask = useSelector(
+ (state: { dataMask?: DataMaskState }) =>
+ state.dataMask || (EMPTY_OBJECT as DataMaskState),
+ );
+
+ const visibleDeckLayersFromRedux = useMemo(() => {
+ const layerVisibilityFilter = Object.values(dataMask).find(
+ mask => mask?.extraFormData?.visible_deckgl_layers !== undefined,
+ );
+ return layerVisibilityFilter?.extraFormData?.visible_deckgl_layers;
+ }, [dataMask]);
+
+ const { layers: apiLayers, isLoading: isLoadingMetadata } =
+ useDeckLayerMetadata(allLayerIds);
+
+ const allLayerIdsFromApi = useMemo(
+ () => apiLayers.map(layer => layer.sliceId),
+ [apiLayers],
+ );
+
+ useEffect(() => {
+ if (
+ !hasInitialized.current &&
+ formData.defaultToAllLayersVisible &&
+ apiLayers.length > 0 &&
+ !filterState?.value &&
+ visibleDeckLayersFromRedux === undefined
+ ) {
+ hasInitialized.current = true;
+ setHiddenLayers([]);
+
+ setDataMask({
+ filterState: {
+ value: [],
+ },
+ extraFormData: {
+ visible_deckgl_layers: allLayerIdsFromApi,
+ } as ExtraFormData,
+ });
+ }
+ }, [
+ formData.defaultToAllLayersVisible,
+ apiLayers.length,
+ filterState?.value,
+ visibleDeckLayersFromRedux,
+ allLayerIdsFromApi,
+ setDataMask,
+ ]);
+
+ const handleLayerChange = useCallback(
+ (selectedHiddenLayers: number[]) => {
+ setHiddenLayers(selectedHiddenLayers);
+
+ const visibleLayers = allLayerIdsFromApi.filter(
+ id => !selectedHiddenLayers.includes(id),
+ );
+
+ setDataMask({
+ filterState: {
+ value: selectedHiddenLayers,
+ },
+ extraFormData: {
+ visible_deckgl_layers: visibleLayers,
+ } as ExtraFormData,
+ });
+ },
+ [allLayerIdsFromApi, setDataMask],
+ );
+
+ const selectOptions = useMemo(
+ () =>
+ apiLayers.map(layer => ({
+ label: `${layer.name} (${layer.type})`,
+ value: layer.sliceId,
+ })),
+ [apiLayers],
+ );
+
+ if (isLoadingMetadata && apiLayers.length === 0) {
+ return (
+ <FilterPluginStyle height={height} width={width}>
+ <div>{t('Loading deck.gl layers...')}</div>
+ </FilterPluginStyle>
+ );
+ }
+
+ return (
+ <FilterPluginStyle height={height} width={width}>
+ {apiLayers.length === 0 ? (
+ <div>{t('No deck.gl multi layer charts found in this dashboard.')}</div>
+ ) : (
+ <FormItem
+ label={
+ <Flex gap={theme.sizeUnit}>
+ <span>{t('Exclude layers (deck.gl)')}</span>
+ <Tooltip
+ title={t(
+ 'Choose layers to hide from all deck.gl Multiple Layer charts in this dashboard.',
+ )}
+ >
+ <span className="tooltip-icon">
+ <Icons.InfoCircleOutlined
+ iconSize="m"
+ iconColor={theme.colorIcon}
+ />
+ </span>
+ </Tooltip>
+ </Flex>
+ }
+ >
+ <Select
+ mode="multiple"
+ value={hiddenLayers}
+ onChange={handleLayerChange}
+ options={selectOptions}
+ placeholder={t('Select layers to hide')}
+ allowClear
+ />
+ </FormItem>
+ )}
+ </FilterPluginStyle>
+ );
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/constants.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.test.ts
similarity index 50%
copy from superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/constants.ts
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.test.ts
index 783f7131fc..2ed2e63a98 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/constants.ts
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.test.ts
@@ -16,23 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { GenericDataType } from '@apache-superset/core/api/core';
+import buildQuery from './buildQuery';
+import { DeckglLayerVisibilityFormData } from './types';
-export const INPUT_HEIGHT = 32;
+test('returns empty query context', () => {
+ const formData: DeckglLayerVisibilityFormData = {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ };
-export const INPUT_WIDTH = 270;
+ const result = buildQuery(formData);
-export const TIME_FILTER_INPUT_WIDTH = 350;
+ expect(result).toHaveProperty('queries');
+ expect(result.queries).toEqual([]);
+});
-export const FILTER_SUPPORTED_TYPES = {
- filter_time: [GenericDataType.Temporal],
- filter_timegrain: [GenericDataType.Temporal],
- filter_timecolumn: [GenericDataType.Temporal],
- filter_select: [
- GenericDataType.Boolean,
- GenericDataType.String,
- GenericDataType.Numeric,
- GenericDataType.Temporal,
- ],
- filter_range: [GenericDataType.Numeric],
-};
+test('returns empty query context with defaultToAllLayersVisible false', () => {
+ const formData: DeckglLayerVisibilityFormData = {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: false,
+ datasource: '1__table',
+ };
+
+ const result = buildQuery(formData);
+
+ expect(result).toHaveProperty('queries');
+ expect(result.queries).toEqual([]);
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.ts
index 10af257b26..8073f99a60 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.ts
@@ -16,28 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { buildQueryContext, BuildQuery } from '@superset-ui/core';
+import { DeckglLayerVisibilityFormData } from './types';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
-
-export interface FilterCardRowProps {
- filter: Filter;
-}
+/**
+ * Layer visibility filter doesn't need to query data from the backend.
+ * It operates on chart metadata available in the dashboard state.
+ * Returns an empty query context.
+ */
+const buildQuery: BuildQuery<DeckglLayerVisibilityFormData> = formData =>
+ buildQueryContext(formData, () => []);
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
-}
+export default buildQuery;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/controlPanel.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/controlPanel.ts
index 10af257b26..5b7bc3dc3b 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/controlPanel.ts
@@ -16,28 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { ControlPanelConfig } from '@superset-ui/chart-controls';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
+const config: ControlPanelConfig = {
+ controlPanelSections: [],
+};
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
-
-export interface FilterCardRowProps {
- filter: Filter;
-}
-
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
-}
+export default config;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/index.ts
similarity index 50%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/index.ts
index ec0dc1353c..4e9630e08c 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/index.ts
@@ -16,21 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
+import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+import controlPanel from './controlPanel';
+import transformProps from './transformProps';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
-};
+export default class DeckglLayerVisibilityCustomizationPlugin extends ChartPlugin {
+ constructor() {
+ const metadata = new ChartMetadata({
+ name: t('Deck.gl Layer Visibility'),
+ description: t('Chart customization to control deck.gl layer visibility'),
+ behaviors: [Behavior.InteractiveChart, Behavior.ChartCustomization],
+ tags: [t('Deckgl'), t('Experimental')],
+ thumbnail: '',
+ enableNoResults: false,
+ });
+
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./DeckglLayerVisibilityCustomizationPlugin'),
+ metadata,
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.test.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.test.ts
new file mode 100644
index 0000000000..65fb401bd6
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.test.ts
@@ -0,0 +1,100 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ChartProps } from '@superset-ui/core';
+import transformProps from './transformProps';
+import { DeckglLayerVisibilityFormData } from './types';
+
+test('transforms props correctly with all required fields', () => {
+ const setDataMaskMock = jest.fn();
+ const formData: DeckglLayerVisibilityFormData = {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ };
+
+ const chartProps = {
+ formData,
+ height: 400,
+ width: 600,
+ filterState: { value: [1, 2] },
+ hooks: { setDataMask: setDataMaskMock },
+ ownState: { availableLayers: [] },
+ } as unknown as ChartProps;
+
+ const result = transformProps(chartProps);
+
+ expect(result).toEqual({
+ formData,
+ height: 400,
+ width: 600,
+ filterState: { value: [1, 2] },
+ setDataMask: setDataMaskMock,
+ ownState: { availableLayers: [] },
+ });
+});
+
+test('transforms props with empty filter state', () => {
+ const setDataMaskMock = jest.fn();
+ const formData: DeckglLayerVisibilityFormData = {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: false,
+ datasource: '1__table',
+ };
+
+ const chartProps = {
+ formData,
+ height: 300,
+ width: 500,
+ filterState: {},
+ hooks: { setDataMask: setDataMaskMock },
+ ownState: undefined,
+ } as unknown as ChartProps;
+
+ const result = transformProps(chartProps);
+
+ expect(result).toEqual({
+ formData,
+ height: 300,
+ width: 500,
+ filterState: {},
+ setDataMask: setDataMaskMock,
+ ownState: undefined,
+ });
+});
+
+test('preserves setDataMask function reference', () => {
+ const setDataMaskMock = jest.fn();
+ const formData: DeckglLayerVisibilityFormData = {
+ viz_type: 'deckgl_layer_visibility',
+ defaultToAllLayersVisible: true,
+ datasource: '1__table',
+ };
+
+ const chartProps = {
+ formData,
+ height: 200,
+ width: 400,
+ filterState: {},
+ hooks: { setDataMask: setDataMaskMock },
+ } as unknown as ChartProps;
+
+ const result = transformProps(chartProps);
+
+ expect(result.setDataMask).toBe(setDataMaskMock);
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.ts
index 10af257b26..f8dcf7a081 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.ts
@@ -16,28 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { ChartProps } from '@superset-ui/core';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
-
-export interface FilterCardRowProps {
- filter: Filter;
-}
+export default function transformProps(chartProps: ChartProps) {
+ const { formData, height, width, filterState, hooks, ownState } = chartProps;
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+ return {
+ formData,
+ height,
+ width,
+ filterState,
+ setDataMask: hooks.setDataMask,
+ ownState,
+ };
}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/types.ts
similarity index 56%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/types.ts
index 10af257b26..35d7ba8648 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/types.ts
@@ -16,28 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { QueryFormData, DataMask } from '@superset-ui/core';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
+export interface DeckglLayerVisibilityFormData extends QueryFormData {
+ defaultToAllLayersVisible?: boolean;
}
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
+export const DEFAULT_FORM_DATA: Partial<DeckglLayerVisibilityFormData> = {
+ defaultToAllLayersVisible: true,
+};
-export interface FilterCardRowProps {
- filter: Filter;
+export interface LayerInfo {
+ sliceId: number;
+ name: string;
+ type: string;
}
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+export interface PluginDeckglLayerVisibilityProps {
+ formData: DeckglLayerVisibilityFormData;
+ height: number;
+ width: number;
+ filterState: {
+ value?: number[];
+ };
+ setDataMask: (dataMask: DataMask) => void;
+ ownState?: {
+ availableLayers?: LayerInfo[];
+ };
}
diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.test.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.test.ts
new file mode 100644
index 0000000000..4b19a795f5
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.test.ts
@@ -0,0 +1,195 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { renderHook } from '@testing-library/react-hooks';
+import { SupersetClient } from '@superset-ui/core';
+import { useDeckLayerMetadata } from './useDeckLayerMetadata';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ SupersetClient: {
+ get: jest.fn(),
+ },
+}));
+
+const mockSupersetClientGet = SupersetClient.get as jest.Mock;
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('returns empty layers when sliceIds is empty', () => {
+ const { result } = renderHook(() => useDeckLayerMetadata([]));
+
+ expect(result.current.layers).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+});
+
+test('fetches layer metadata successfully', async () => {
+ const mockResponse = {
+ json: {
+ result: [
+ { id: 1, slice_name: 'Layer 1', viz_type: 'deck_scatter' },
+ { id: 2, slice_name: 'Layer 2', viz_type: 'deck_arc' },
+ ],
+ },
+ };
+ mockSupersetClientGet.mockResolvedValue(mockResponse);
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useDeckLayerMetadata([1, 2]),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.layers).toEqual([
+ { sliceId: 1, name: 'Layer 1', type: 'deck_scatter' },
+ { sliceId: 2, name: 'Layer 2', type: 'deck_arc' },
+ ]);
+ expect(result.current.error).toBe(null);
+ expect(mockSupersetClientGet).toHaveBeenCalledWith({
+ endpoint: expect.stringContaining('/api/v1/chart/?q='),
+ });
+});
+
+test('handles API error and returns fallback layers', async () => {
+ const errorMessage = 'Network error';
+ mockSupersetClientGet.mockRejectedValue(new Error(errorMessage));
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useDeckLayerMetadata([1, 2, 3]),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(errorMessage);
+ expect(result.current.layers).toEqual([
+ { sliceId: 1, name: 'Layer 1', type: 'unknown' },
+ { sliceId: 2, name: 'Layer 2', type: 'unknown' },
+ { sliceId: 3, name: 'Layer 3', type: 'unknown' },
+ ]);
+});
+
+test('handles non-Error object rejection', async () => {
+ mockSupersetClientGet.mockRejectedValue('String error');
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useDeckLayerMetadata([1]),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe('Unknown error');
+ expect(result.current.layers).toEqual([
+ { sliceId: 1, name: 'Layer 1', type: 'unknown' },
+ ]);
+});
+
+test('refetches when sliceIds change', async () => {
+ const mockResponse1 = {
+ json: {
+ result: [{ id: 1, slice_name: 'Layer 1', viz_type: 'deck_scatter' }],
+ },
+ };
+ const mockResponse2 = {
+ json: {
+ result: [
+ { id: 2, slice_name: 'Layer 2', viz_type: 'deck_arc' },
+ { id: 3, slice_name: 'Layer 3', viz_type: 'deck_geojson' },
+ ],
+ },
+ };
+
+ mockSupersetClientGet
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2);
+
+ const { result, rerender, waitForNextUpdate } = renderHook(
+ ({ ids }) => useDeckLayerMetadata(ids),
+ {
+ initialProps: { ids: [1] },
+ },
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.layers).toHaveLength(1);
+ expect(result.current.layers[0].sliceId).toBe(1);
+
+ rerender({ ids: [2, 3] });
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.layers).toHaveLength(2);
+ expect(result.current.layers[0].sliceId).toBe(2);
+ expect(result.current.layers[1].sliceId).toBe(3);
+ expect(mockSupersetClientGet).toHaveBeenCalledTimes(2);
+});
+
+test('handles empty result from API', async () => {
+ const mockResponse = {
+ json: {
+ result: [],
+ },
+ };
+ mockSupersetClientGet.mockResolvedValue(mockResponse);
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useDeckLayerMetadata([1, 2]),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.layers).toEqual([]);
+ expect(result.current.error).toBe(null);
+});
+
+test('does not refetch when sliceIds array has same values', async () => {
+ const mockResponse = {
+ json: {
+ result: [{ id: 1, slice_name: 'Layer 1', viz_type: 'deck_scatter' }],
+ },
+ };
+ mockSupersetClientGet.mockResolvedValue(mockResponse);
+
+ const { result, rerender, waitForNextUpdate } = renderHook(
+ ({ ids }) => useDeckLayerMetadata(ids),
+ {
+ initialProps: { ids: [1] },
+ },
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.isLoading).toBe(false);
+
+ const callCount = mockSupersetClientGet.mock.calls.length;
+
+ rerender({ ids: [1] });
+
+ expect(mockSupersetClientGet).toHaveBeenCalledTimes(callCount);
+});
diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.ts b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.ts
new file mode 100644
index 0000000000..dfa7c65632
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.ts
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import rison from 'rison';
+import { LayerInfo } from './types';
+
+interface SliceResponse {
+ id: number;
+ slice_name: string;
+ viz_type: string;
+}
+
+export const useDeckLayerMetadata = (
+ sliceIds: number[],
+): {
+ layers: LayerInfo[];
+ isLoading: boolean;
+ error: string | null;
+} => {
+ const [layers, setLayers] = useState<LayerInfo[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (!sliceIds || sliceIds.length === 0) {
+ setLayers([]);
+ return;
+ }
+
+ const fetchMetadata = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const queryParams = rison.encode({
+ columns: ['id', 'slice_name', 'viz_type'],
+ filters: [{ col: 'id', opr: 'in', value: sliceIds }],
+ });
+ const endpoint = `/api/v1/chart/?q=${queryParams}`;
+
+ const response = await SupersetClient.get({ endpoint });
+
+ const slices: SliceResponse[] = response.json.result || [];
+
+ const layerInfos: LayerInfo[] = slices.map(slice => ({
+ sliceId: slice.id,
+ name: slice.slice_name,
+ type: slice.viz_type,
+ }));
+
+ setLayers(layerInfos);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+
+ const fallbackLayers = sliceIds.map(id => ({
+ sliceId: id,
+ name: `Layer ${id}`,
+ type: 'unknown',
+ }));
+ setLayers(fallbackLayers);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchMetadata();
+ }, [sliceIds.join(',')]);
+
+ return { layers, isLoading, error };
+};
diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/DynamicGroupByPlugin.tsx b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/DynamicGroupByPlugin.tsx
new file mode 100644
index 0000000000..8a8c045e0d
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/DynamicGroupByPlugin.tsx
@@ -0,0 +1,140 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { t, tn } from '@apache-superset/core';
+import { ensureIsArray, ExtraFormData } from '@superset-ui/core';
+import { useEffect, useState, useMemo } from 'react';
+import {
+ FormItem,
+ type FormItemProps,
+ Select,
+ type SelectValue,
+} from '@superset-ui/core/components';
+import { FilterPluginStyle, StatusMessage } from '../common';
+import { PluginFilterGroupByProps, ColumnOption, ColumnData } from './types';
+
+const EMPTY_OBJECT = {};
+
+export default function PluginFilterDynamicGroupBy(
+ props: PluginFilterGroupByProps,
+) {
+ const {
+ data,
+ formData,
+ height,
+ width,
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ filterState,
+ inputRef,
+ } = props;
+ const { defaultValue } = formData;
+
+ const [value, setValue] = useState<string[]>(
+ ensureIsArray<string>(defaultValue ?? []),
+ );
+
+ const handleChange = (values: SelectValue) => {
+ const resultValue: string[] = ensureIsArray<string>(
+ values as string | string[] | null | undefined,
+ );
+
+ const extraFormData: ExtraFormData = {
+ custom_form_data: {
+ groupby: resultValue,
+ },
+ };
+
+ setValue(resultValue);
+ setDataMask({
+ extraFormData,
+ filterState: {
+ label: resultValue.join(', '),
+ value: resultValue.length ? resultValue : null,
+ },
+ });
+ };
+
+ useEffect(() => {
+ handleChange(defaultValue ?? []);
+ }, [JSON.stringify(defaultValue)]);
+
+ useEffect(() => {
+ handleChange(filterState.value ?? []);
+ }, [JSON.stringify(filterState.value)]);
+
+ const placeholderText =
+ (data || []).length === 0
+ ? t('No data')
+ : tn('%s option', '%s options', data.length, data.length);
+
+ const formItemData: FormItemProps = useMemo(() => {
+ if (filterState.validateMessage) {
+ return {
+ extra: (
+ <StatusMessage status={filterState.validateStatus}>
+ {filterState.validateMessage}
+ </StatusMessage>
+ ),
+ };
+ }
+ return EMPTY_OBJECT as FormItemProps;
+ }, [filterState.validateMessage, filterState.validateStatus]);
+
+ const options = useMemo(
+ () =>
+ (data || []).map((row: ColumnOption | ColumnData) => {
+ const columnName = 'column_name' in row ? row.column_name : row.value;
+ const label =
+ ('verbose_name' in row && row.verbose_name) ||
+ ('label' in row && row.label) ||
+ columnName;
+ return {
+ label,
+ value: columnName,
+ };
+ }),
+ [data],
+ );
+
+ return (
+ <FilterPluginStyle height={height} width={width}>
+ <FormItem validateStatus={filterState.validateStatus} {...formItemData}>
+ <div onMouseEnter={setHoveredFilter} onMouseLeave={unsetHoveredFilter}>
+ <Select
+ name={formData.nativeFilterId}
+ allowClear
+ mode="multiple"
+ value={value}
+ placeholder={placeholderText}
+ onChange={handleChange}
+ onBlur={unsetFocusedFilter}
+ onFocus={setFocusedFilter}
+ ref={inputRef}
+ options={options}
+ onOpenChange={setFilterActive}
+ />
+ </div>
+ </FormItem>
+ </FilterPluginStyle>
+ );
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts
index 10af257b26..6c9eeff43d 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts
@@ -16,28 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { buildQueryContext, QueryFormData } from '@superset-ui/core';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
-
-export interface FilterCardRowProps {
- filter: Filter;
-}
-
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+export default function buildQuery(formData: QueryFormData) {
+ return buildQueryContext(formData, () => [
+ {
+ result_type: 'columns',
+ columns: [],
+ metrics: [],
+ orderby: [],
+ },
+ ]);
}
diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/controlPanel.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/controlPanel.ts
new file mode 100644
index 0000000000..a09523b6cf
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/controlPanel.ts
@@ -0,0 +1,80 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ControlPanelConfig } from '@superset-ui/chart-controls';
+import { t } from '@apache-superset/core';
+
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'groupby',
+ config: {
+ type: 'SelectControl',
+ label: t('Column'),
+ description: t('Column to group by'),
+ default: null,
+ clearable: true,
+ required: true,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: t('UI Configuration'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'canSelectMultiple',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Can select multiple values'),
+ default: true,
+ renderTrigger: true,
+ resetConfig: true,
+ affectsDataMask: true,
+ description: t('Allow users to select multiple values'),
+ },
+ },
+ ],
+ [
+ {
+ name: 'enableEmptyFilter',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Chart customization value is required'),
+ default: false,
+ renderTrigger: true,
+ description: t(
+ 'User must select a value before applying the chart customization',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ ],
+};
+
+export default config;
diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png
new file mode 100644
index 0000000000..7afef30bd4
Binary files /dev/null and b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png differ
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts
similarity index 51%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts
index ec0dc1353c..4b81919fca 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts
@@ -16,21 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
+import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+import controlPanel from './controlPanel';
+import transformProps from './transformProps';
+import thumbnail from './images/thumbnail.png';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
-};
+export default class ChartCustomizationDynamicGroupByPlugin extends ChartPlugin {
+ constructor() {
+ const metadata = new ChartMetadata({
+ name: t('Dynamic group by'),
+ description: t('Dynamically select grouping columns from a dataset'),
+ behaviors: [Behavior.ChartCustomization],
+ tags: [t('Grouping'), t('Dynamic')],
+ thumbnail,
+ datasourceCount: 1,
+ });
+
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./DynamicGroupByPlugin'),
+ metadata,
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts
similarity index 50%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts
index 10af257b26..b86d04c0c4 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts
@@ -16,28 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { ChartProps } from '@superset-ui/core';
+import { noOp } from 'src/utils/common';
+import { DEFAULT_FORM_DATA } from './types';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
+export default function transformProps(chartProps: ChartProps) {
+ const { formData, height, hooks, queriesData, width, filterState, inputRef } =
+ chartProps;
+ const {
+ setDataMask = noOp,
+ setHoveredFilter = noOp,
+ unsetHoveredFilter = noOp,
+ setFocusedFilter = noOp,
+ unsetFocusedFilter = noOp,
+ setFilterActive = noOp,
+ } = hooks;
-export interface FilterCardRowProps {
- filter: Filter;
-}
+ const { data } = queriesData[0];
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+ return {
+ filterState,
+ width,
+ height,
+ data,
+ formData: { ...DEFAULT_FORM_DATA, ...formData },
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ inputRef,
+ };
}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/types.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/types.ts
similarity index 51%
rename from superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/types.ts
rename to superset-frontend/src/chartCustomizations/components/DynamicGroupBy/types.ts
index ab2d536b03..46374249da 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/types.ts
@@ -16,9 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { DataMask } from '@superset-ui/core';
+import { FilterState, QueryFormData } from '@superset-ui/core';
+import { RefObject } from 'react';
+import type { RefSelectProps } from '@superset-ui/core/components';
+import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
-interface DatasetReference {
+export interface DatasetReference {
value: string | number;
label?: string;
table_name?: string;
@@ -30,61 +33,50 @@ export interface ColumnOption {
value: string;
}
-export interface GroupByCustomization {
- name: string;
- dataset: string | number | DatasetReference | null;
+interface PluginFilterGroupByCustomizeProps {
+ dataset?: string | number | DatasetReference | null;
datasetInfo?: {
label: string;
value: number;
table_name: string;
};
- column: string | string[] | null;
+ column?: string | string[] | null;
description?: string;
sortFilter?: boolean;
sortAscending?: boolean;
sortMetric?: string;
hasDefaultValue?: boolean;
- defaultValue?: string;
+ defaultValue?: string | string[] | null;
isRequired?: boolean;
selectFirst?: boolean;
- defaultDataMask?: DataMask;
- defaultValueQueriesData?: ColumnOption[] | null;
- aggregation?: string;
canSelectMultiple?: boolean;
- controlValues?: {
- enableEmptyFilter?: boolean;
- };
+ aggregation?: string;
+ enableEmptyFilter?: boolean;
+ inputRef?: RefObject<HTMLInputElement>;
}
-export interface FilterOption {
- label: string;
- value: string;
-}
+export type PluginFilterGroupByQueryFormData = QueryFormData &
+ PluginFilterStylesProps &
+ PluginFilterGroupByCustomizeProps;
-export interface ChartCustomizationItem {
- id: string;
- title?: string;
- removed?: boolean;
- dataset?: string | null;
- description?: string;
- removeTimerId?: number;
- chartId?: number;
- settings?: {
- sortFilter: boolean;
- hasDefaultValue: boolean;
- isRequired: boolean;
- selectFirstByDefault: boolean;
- };
- customization: GroupByCustomization;
+export interface ColumnData {
+ column_name: string;
+ verbose_name?: string | null;
+ dtype?: number;
}
-export interface ChartCustomizationChangesType {
- modified: string[];
- deleted: string[];
- reordered: string[];
-}
+export type PluginFilterGroupByProps = PluginFilterStylesProps & {
+ data: (ColumnOption | ColumnData)[];
+ filterState: FilterState;
+ formData: PluginFilterGroupByQueryFormData;
+ inputRef: RefObject<RefSelectProps>;
+} & PluginFilterHooks;
-export interface ChartCustomizationRemoval {
- isPending: boolean;
- timerId: number;
-}
+export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {
+ dataset: null,
+ column: null,
+ sortFilter: false,
+ sortAscending: true,
+ canSelectMultiple: true,
+ defaultValue: null,
+};
diff --git a/superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx b/superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx
new file mode 100644
index 0000000000..6faf49f117
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx
@@ -0,0 +1,127 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { t, tn } from '@apache-superset/core';
+import { ensureIsArray, ExtraFormData } from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/api/core';
+import { useEffect, useState } from 'react';
+import {
+ FormItem,
+ type FormItemProps,
+ Select,
+} from '@superset-ui/core/components';
+import { FilterPluginStyle, StatusMessage } from '../common';
+import { PluginFilterTimeColumnProps } from './types';
+
+export default function PluginFilterTimeColumn(
+ props: PluginFilterTimeColumnProps,
+) {
+ const {
+ data,
+ formData,
+ height,
+ width,
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ filterState,
+ inputRef,
+ } = props;
+ const { defaultValue } = formData;
+
+ const [value, setValue] = useState<string[]>(defaultValue ?? []);
+
+ const handleChange = (value?: string[] | string | null) => {
+ const resultValue: string[] = ensureIsArray<string>(value);
+ setValue(resultValue);
+ const extraFormData: ExtraFormData = {};
+ if (resultValue.length) {
+ extraFormData.granularity_sqla = resultValue[0];
+ }
+
+ setDataMask({
+ extraFormData,
+ filterState: {
+ value: resultValue.length ? resultValue : null,
+ },
+ });
+ };
+
+ useEffect(() => {
+ handleChange(defaultValue ?? null);
+ // I think after Config Modal update some filter it re-creates default value for all other filters
+ // so we can process it like this `JSON.stringify` or start to use `Immer`
+ }, [JSON.stringify(defaultValue)]);
+
+ useEffect(() => {
+ handleChange(filterState.value ?? null);
+ }, [JSON.stringify(filterState.value)]);
+
+ const timeColumns = (data || []).filter(
+ row => row.dtype === GenericDataType.Temporal,
+ );
+
+ const placeholderText =
+ timeColumns.length === 0
+ ? t('No time columns')
+ : tn('%s option', '%s options', timeColumns.length, timeColumns.length);
+
+ const formItemData: FormItemProps = {};
+ if (filterState.validateMessage) {
+ formItemData.extra = (
+ <StatusMessage status={filterState.validateStatus}>
+ {filterState.validateMessage}
+ </StatusMessage>
+ );
+ }
+
+ const options = timeColumns.map(
+ (row: { column_name: string; verbose_name: string | null }) => {
+ const { column_name: columnName, verbose_name: verboseName } = row;
+ return {
+ label: verboseName ?? columnName,
+ value: columnName,
+ };
+ },
+ );
+
+ return (
+ <FilterPluginStyle height={height} width={width}>
+ <FormItem validateStatus={filterState.validateStatus} {...formItemData}>
+ <Select
+ name={formData.nativeFilterId}
+ allowClear
+ value={value}
+ placeholder={placeholderText}
+ // @ts-ignore
+ onChange={handleChange}
+ onBlur={unsetFocusedFilter}
+ onFocus={setFocusedFilter}
+ onMouseEnter={setHoveredFilter}
+ onMouseLeave={unsetHoveredFilter}
+ ref={inputRef}
+ options={options}
+ onOpenChange={setFilterActive}
+ />
+ </FormItem>
+ </FilterPluginStyle>
+ );
+}
diff --git a/superset-frontend/src/chartCustomizations/components/TimeColumn/buildQuery.ts b/superset-frontend/src/chartCustomizations/components/TimeColumn/buildQuery.ts
new file mode 100644
index 0000000000..c271a69795
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/buildQuery.ts
@@ -0,0 +1,44 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { buildQueryContext, QueryFormData } from '@superset-ui/core';
+
+/**
+ * The buildQuery function is used to create an instance of QueryContext that's
+ * sent to the chart data endpoint. In addition to containing information of which
+ * datasource to use, it specifies the type (e.g. full payload, samples, query) and
+ * format (e.g. CSV or JSON) of the result and whether or not to force refresh the data from
+ * the datasource as opposed to using a cached copy of the data, if available.
+ *
+ * More importantly though, QueryContext contains a property `queries`, which is an array of
+ * QueryObjects specifying individual data requests to be made. A QueryObject specifies which
+ * columns, metrics and filters, among others, to use during the query. Usually it will be enough
+ * to specify just one query based on the baseQueryObject, but for some more advanced use cases
+ * it is possible to define post processing operations in the QueryObject, or multiple queries
+ * if a viz needs multiple different result sets.
+ */
+export default function buildQuery(formData: QueryFormData) {
+ return buildQueryContext(formData, () => [
+ {
+ result_type: 'columns',
+ columns: [],
+ metrics: [],
+ orderby: [],
+ },
+ ]);
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/TimeColumn/controlPanel.ts
similarity index 56%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/TimeColumn/controlPanel.ts
index ec0dc1353c..cef113e386 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/controlPanel.ts
@@ -16,21 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
+import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: t('UI Configuration'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'enableEmptyFilter',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Filter value is required'),
+ default: false,
+ renderTrigger: true,
+ description: t(
+ 'User must select a value before applying the filter',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ ],
};
+
+export default config;
diff --git a/superset-frontend/src/chartCustomizations/components/TimeColumn/images/thumbnail.png b/superset-frontend/src/chartCustomizations/components/TimeColumn/images/thumbnail.png
new file mode 100644
index 0000000000..7afef30bd4
Binary files /dev/null and b/superset-frontend/src/chartCustomizations/components/TimeColumn/images/thumbnail.png differ
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/TimeColumn/index.ts
similarity index 52%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/TimeColumn/index.ts
index ec0dc1353c..6971be03d2 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/index.ts
@@ -16,21 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
+import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+import controlPanel from './controlPanel';
+import transformProps from './transformProps';
+import thumbnail from './images/thumbnail.png';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
-};
+export default class ChartCustomizationTimeColumnPlugin extends ChartPlugin {
+ constructor() {
+ const metadata = new ChartMetadata({
+ name: t('Time column'),
+ description: t('Time column chart customization plugin'),
+ behaviors: [Behavior.InteractiveChart, Behavior.ChartCustomization],
+ tags: [t('Experimental')],
+ thumbnail,
+ });
+
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./TimeColumnFilterPlugin'),
+ metadata,
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/src/chartCustomizations/components/TimeColumn/transformProps.ts b/superset-frontend/src/chartCustomizations/components/TimeColumn/transformProps.ts
new file mode 100644
index 0000000000..1f50260273
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/transformProps.ts
@@ -0,0 +1,60 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ChartProps } from '@superset-ui/core';
+import { noOp } from 'src/utils/common';
+import { DEFAULT_FORM_DATA } from './types';
+
+export default function transformProps(chartProps: ChartProps) {
+ const {
+ behaviors,
+ formData,
+ height,
+ hooks,
+ queriesData,
+ width,
+ filterState,
+ inputRef,
+ } = chartProps;
+ const {
+ setDataMask = noOp,
+ setHoveredFilter = noOp,
+ unsetHoveredFilter = noOp,
+ setFocusedFilter = noOp,
+ unsetFocusedFilter = noOp,
+ setFilterActive = noOp,
+ } = hooks;
+
+ const { data } = queriesData[0];
+
+ return {
+ filterState,
+ behaviors,
+ width,
+ height,
+ data,
+ formData: { ...DEFAULT_FORM_DATA, ...formData },
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ inputRef,
+ };
+}
diff --git a/superset-frontend/src/chartCustomizations/components/TimeColumn/types.ts b/superset-frontend/src/chartCustomizations/components/TimeColumn/types.ts
new file mode 100644
index 0000000000..9767c53f12
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/types.ts
@@ -0,0 +1,48 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ Behavior,
+ DataRecord,
+ FilterState,
+ QueryFormData,
+} from '@superset-ui/core';
+import { RefObject } from 'react';
+import type { RefSelectProps } from '@superset-ui/core/components';
+import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
+
+interface PluginFilterTimeColumnCustomizeProps {
+ defaultValue?: string[] | null;
+ inputRef?: RefObject<HTMLInputElement>;
+}
+
+export type PluginFilterTimeColumnQueryFormData = QueryFormData &
+ PluginFilterStylesProps &
+ PluginFilterTimeColumnCustomizeProps;
+
+export type PluginFilterTimeColumnProps = PluginFilterStylesProps & {
+ behaviors: Behavior[];
+ data: DataRecord[];
+ filterState: FilterState;
+ formData: PluginFilterTimeColumnQueryFormData;
+ inputRef: RefObject<RefSelectProps>;
+} & PluginFilterHooks;
+
+export const DEFAULT_FORM_DATA: PluginFilterTimeColumnCustomizeProps = {
+ defaultValue: null,
+};
diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx
new file mode 100644
index 0000000000..4fdd0a33aa
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx
@@ -0,0 +1,159 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { t, tn } from '@apache-superset/core';
+import {
+ ensureIsArray,
+ ExtraFormData,
+ TimeGranularity,
+} from '@superset-ui/core';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ FormItem,
+ type FormItemProps,
+ LabeledValue,
+ Select,
+ type SelectValue,
+} from '@superset-ui/core/components';
+import { propertyComparator } from '@superset-ui/core/components/Select/utils';
+import { FilterPluginStyle, StatusMessage } from '../common';
+import { PluginFilterTimeGrainProps } from './types';
+
+export default function PluginFilterTimegrain(
+ props: PluginFilterTimeGrainProps,
+) {
+ const {
+ data,
+ formData,
+ height,
+ width,
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ filterState,
+ inputRef,
+ } = props;
+ const { defaultValue } = formData;
+
+ const [value, setValue] = useState<string[]>(defaultValue ?? []);
+ const durationMap = useMemo(
+ () =>
+ data.reduce(
+ (agg, { duration, name }: { duration: string; name: string }) => ({
+ ...agg,
+ [duration]: name,
+ }),
+ {} as { [key in string]: string },
+ ),
+ [JSON.stringify(data)],
+ );
+
+ const handleChange = (values: SelectValue) => {
+ const resultValue: string[] = ensureIsArray<string>(
+ values as string | string[] | null | undefined,
+ );
+ const [timeGrain] = resultValue;
+ const label = timeGrain ? durationMap[timeGrain] : undefined;
+
+ const extraFormData: ExtraFormData = {};
+ if (timeGrain) {
+ extraFormData.time_grain_sqla = timeGrain as TimeGranularity;
+ }
+ setValue(resultValue);
+ setDataMask({
+ extraFormData,
+ filterState: {
+ label,
+ value: resultValue.length ? resultValue : null,
+ },
+ });
+ };
+
+ useEffect(() => {
+ handleChange(defaultValue ?? []);
+ // Config Modal updates regenerate default values for filters
+ // Using JSON.stringify for deep comparison until Immer is adopted
+ }, [JSON.stringify(defaultValue)]);
+
+ useEffect(() => {
+ handleChange(filterState.value ?? []);
+ }, [JSON.stringify(filterState.value)]);
+
+ const placeholderText =
+ (data || []).length === 0
+ ? t('No data')
+ : tn('%s option', '%s options', data.length, data.length);
+
+ const formItemData: FormItemProps = {};
+ if (filterState.validateMessage) {
+ formItemData.extra = (
+ <StatusMessage status={filterState.validateStatus}>
+ {filterState.validateMessage}
+ </StatusMessage>
+ );
+ }
+
+ const options = (data || []).map(
+ (row: { name: string; duration: string }) => {
+ const { name, duration } = row;
+ return {
+ label: name,
+ value: duration,
+ };
+ },
+ );
+
+ const sortComparator = useCallback(
+ (a: LabeledValue, b: LabeledValue) => {
+ if (formData.sortAscending === undefined) {
+ return 0;
+ }
+ const labelComparator = propertyComparator('label');
+ if (formData.sortAscending) {
+ return labelComparator(a, b);
+ }
+ return labelComparator(b, a);
+ },
+ [formData.sortAscending],
+ );
+
+ return (
+ <FilterPluginStyle height={height} width={width}>
+ <FormItem validateStatus={filterState.validateStatus} {...formItemData}>
+ <div onMouseEnter={setHoveredFilter} onMouseLeave={unsetHoveredFilter}>
+ <Select
+ name={formData.nativeFilterId}
+ allowClear
+ value={value}
+ placeholder={placeholderText}
+ onChange={handleChange}
+ onBlur={unsetFocusedFilter}
+ onFocus={setFocusedFilter}
+ ref={inputRef}
+ options={options}
+ onOpenChange={setFilterActive}
+ sortComparator={sortComparator}
+ />
+ </div>
+ </FormItem>
+ </FilterPluginStyle>
+ );
+}
diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/buildQuery.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/buildQuery.ts
new file mode 100644
index 0000000000..5a20aef2a8
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/buildQuery.ts
@@ -0,0 +1,44 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { buildQueryContext, QueryFormData } from '@superset-ui/core';
+
+/**
+ * The buildQuery function is used to create an instance of QueryContext that's
+ * sent to the chart data endpoint. In addition to containing information of which
+ * datasource to use, it specifies the type (e.g. full payload, samples, query) and
+ * format (e.g. CSV or JSON) of the result and whether or not to force refresh the data from
+ * the datasource as opposed to using a cached copy of the data, if available.
+ *
+ * More importantly though, QueryContext contains a property `queries`, which is an array of
+ * QueryObjects specifying individual data requests to be made. A QueryObject specifies which
+ * columns, metrics and filters, among others, to use during the query. Usually it will be enough
+ * to specify just one query based on the baseQueryObject, but for some more advanced use cases
+ * it is possible to define post processing operations in the QueryObject, or multiple queries
+ * if a viz needs multiple different result sets.
+ */
+export default function buildQuery(formData: QueryFormData) {
+ return buildQueryContext(formData, () => [
+ {
+ result_type: 'timegrains',
+ columns: [],
+ metrics: [],
+ orderby: [],
+ },
+ ]);
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts
similarity index 56%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts
index ec0dc1353c..49049f70ba 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts
@@ -16,21 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
+import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: t('UI Configuration'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'enableEmptyFilter',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Customization value is required'),
+ default: false,
+ renderTrigger: true,
+ description: t(
+ 'User must select a value before applying the customization',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ ],
};
+
+export default config;
diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/images/thumbnail.png b/superset-frontend/src/chartCustomizations/components/TimeGrain/images/thumbnail.png
new file mode 100644
index 0000000000..7afef30bd4
Binary files /dev/null and b/superset-frontend/src/chartCustomizations/components/TimeGrain/images/thumbnail.png differ
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx b/superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts
similarity index 52%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
copy to superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts
index ec0dc1353c..8c2c95632c 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/TypeRow.tsx
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts
@@ -16,21 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useMemo } from 'react';
import { t } from '@apache-superset/core';
-import { getChartMetadataRegistry } from '@superset-ui/core';
-import { Row, RowLabel, RowValue } from './Styles';
-import { FilterCardRowProps } from './types';
+import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+import controlPanel from './controlPanel';
+import transformProps from './transformProps';
+import thumbnail from './images/thumbnail.png';
-export const TypeRow = ({ filter }: FilterCardRowProps) => {
- const metadata = useMemo(
- () => getChartMetadataRegistry().get(filter.filterType),
- [filter.filterType],
- );
- return (
- <Row>
- <RowLabel>{t('Filter type')}</RowLabel>
- <RowValue>{metadata?.name}</RowValue>
- </Row>
- );
-};
+export default class ChartCustomizationTimeGrainPlugin extends ChartPlugin {
+ constructor() {
+ const metadata = new ChartMetadata({
+ name: t('Time grain'),
+ description: t('Time grain chart customization plugin'),
+ behaviors: [Behavior.InteractiveChart, Behavior.ChartCustomization],
+ tags: [t('Experimental')],
+ thumbnail,
+ });
+
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./TimeGrainFilterPlugin'),
+ metadata,
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts
similarity index 50%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts
index 10af257b26..b86d04c0c4 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts
@@ -16,28 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
+import { ChartProps } from '@superset-ui/core';
+import { noOp } from 'src/utils/common';
+import { DEFAULT_FORM_DATA } from './types';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
+export default function transformProps(chartProps: ChartProps) {
+ const { formData, height, hooks, queriesData, width, filterState, inputRef } =
+ chartProps;
+ const {
+ setDataMask = noOp,
+ setHoveredFilter = noOp,
+ unsetHoveredFilter = noOp,
+ setFocusedFilter = noOp,
+ unsetFocusedFilter = noOp,
+ setFilterActive = noOp,
+ } = hooks;
-export interface FilterCardRowProps {
- filter: Filter;
-}
+ const { data } = queriesData[0];
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+ return {
+ filterState,
+ width,
+ height,
+ data,
+ formData: { ...DEFAULT_FORM_DATA, ...formData },
+ setDataMask,
+ setHoveredFilter,
+ unsetHoveredFilter,
+ setFocusedFilter,
+ unsetFocusedFilter,
+ setFilterActive,
+ inputRef,
+ };
}
diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts
new file mode 100644
index 0000000000..611566f0d1
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { FilterState, QueryFormData, DataRecord } from '@superset-ui/core';
+import { RefObject } from 'react';
+import type { RefSelectProps } from '@superset-ui/core/components';
+import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
+
+interface PluginFilterTimeGrainCustomizeProps {
+ defaultValue?: string[] | null;
+ inputRef?: RefObject<HTMLInputElement>;
+}
+
+export type PluginFilterTimeGrainQueryFormData = QueryFormData &
+ PluginFilterStylesProps &
+ PluginFilterTimeGrainCustomizeProps;
+
+export type PluginFilterTimeGrainProps = PluginFilterStylesProps & {
+ data: DataRecord[];
+ filterState: FilterState;
+ formData: PluginFilterTimeGrainQueryFormData;
+ inputRef: RefObject<RefSelectProps>;
+} & PluginFilterHooks;
+
+export const DEFAULT_FORM_DATA: PluginFilterTimeGrainCustomizeProps = {
+ defaultValue: null,
+};
diff --git a/superset-frontend/src/chartCustomizations/components/common.ts b/superset-frontend/src/chartCustomizations/components/common.ts
new file mode 100644
index 0000000000..38d98a5ae0
--- /dev/null
+++ b/superset-frontend/src/chartCustomizations/components/common.ts
@@ -0,0 +1,57 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { styled } from '@apache-superset/core/ui';
+import { FormItem } from '@superset-ui/core/components';
+import { PluginFilterStylesProps } from './types';
+
+export const RESPONSIVE_WIDTH = 0;
+
+export const FilterPluginStyle = styled.div<PluginFilterStylesProps>`
+ min-height: ${({ height }) => height}px;
+ width: ${({ width }) => (width === RESPONSIVE_WIDTH ? '100%' : `${width}px`)};
+`;
+
+export const StyledFormItem = styled(FormItem)`
+ &.ant-row.ant-form-item {
+ margin: 0;
+ }
+`;
+
+export const StatusMessage = styled.div<{
+ status?: 'error' | 'warning' | 'info' | 'help';
+ centerText?: boolean;
+}>`
+ color: ${({ theme, status = 'error' }) => {
+ if (status === 'help') {
+ return theme.colorTextSecondary;
+ }
+ switch (status) {
+ case 'error':
+ return theme.colorError;
+ case 'warning':
+ return theme.colorWarning;
+ case 'info':
+ return theme.colorInfo;
+ default:
+ return theme.colorError;
+ }
+ }};
+ text-align: ${({ centerText }) => (centerText ? 'center' : 'left')};
+ width: 100%;
+`;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/index.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/index.ts
index 10af257b26..be5ee7956f 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/index.ts
@@ -16,28 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
-
-export interface FilterCardRowProps {
- filter: Filter;
-}
-
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
-}
+export { default as ChartCustomizationTimeColumnPlugin } from './TimeColumn';
+export { default as ChartCustomizationTimeGrainPlugin } from './TimeGrain';
+export { default as ChartCustomizationDynamicGroupBy } from './DynamicGroupBy';
+export { default as DeckglLayerVisibilityCustomizationPlugin } from './DeckglLayerVisibility';
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts b/superset-frontend/src/chartCustomizations/components/types.ts
similarity index 61%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
copy to superset-frontend/src/chartCustomizations/components/types.ts
index 10af257b26..2d9d5c9632 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/types.ts
+++ b/superset-frontend/src/chartCustomizations/components/types.ts
@@ -16,28 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ReactNode } from 'react';
-import { Filter } from '@superset-ui/core';
-export enum FilterCardPlacement {
- Right = 'right',
- Bottom = 'bottom',
- Left = 'left',
-}
-
-export interface FilterCardProps {
- children: ReactNode;
- filter: Filter;
- getPopupContainer?: (node: HTMLElement) => HTMLElement;
- isVisible?: boolean;
- placement: FilterCardPlacement;
-}
+import { SetDataMaskHook } from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
-export interface FilterCardRowProps {
- filter: Filter;
+export interface PluginFilterStylesProps {
+ height: number;
+ width: number;
+ orientation?: FilterBarOrientation;
+ overflow?: boolean;
}
-export interface DependencyValueProps {
- dependency: Filter;
- hasSeparator?: boolean;
+export interface PluginFilterHooks {
+ setDataMask: SetDataMaskHook;
+ setFocusedFilter: () => void;
+ unsetFocusedFilter: () => void;
+ setHoveredFilter: () => void;
+ unsetHoveredFilter: () => void;
+ setFilterActive: (isActive: boolean) => void;
}
diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts
index fddd72288a..efd4a516e4 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -190,6 +190,13 @@ export enum FilterPlugins {
TimeGrain = 'filter_timegrain',
}
+export enum ChartCustomizationPlugins {
+ DynamicGroupBy = 'chart_customization_dynamic_groupby',
+ TimeGrain = 'chart_customization_timegrain',
+ TimeColumn = 'chart_customization_timecolumn',
+ DeckglLayerVisibility = 'chart_customization_deckgl_layer_visibility',
+}
+
export enum Actions {
CREATE = 'create',
UPDATE = 'update',
diff --git a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
index b81fe81825..672cd10420 100644
--- a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
+++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts
@@ -19,77 +19,58 @@
import { AnyAction } from 'redux';
import { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { t } from '@apache-superset/core';
-import { makeApi, getClientErrorObject, DataMask } from '@superset-ui/core';
+import {
+ makeApi,
+ getClientErrorObject,
+ ChartCustomization,
+ ChartCustomizationDivider,
+ ColumnOption,
+ Filter,
+ Filters,
+} from '@superset-ui/core';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { DashboardInfo, RootState } from 'src/dashboard/types';
import {
- ChartCustomizationItem,
- FilterOption,
- ColumnOption,
-} from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
-import { triggerQuery } from 'src/components/Chart/chartAction';
-import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
-import { onSave } from './dashboardState';
+ removeDataMask,
+ setDataMaskForFilterChangesComplete,
+} from 'src/dataMask/actions';
+import { dashboardInfoChanged } from './dashboardInfo';
+import {
+ SET_NATIVE_FILTERS_CONFIG_COMPLETE,
+ SET_IN_SCOPE_STATUS_OF_FILTERS,
+} from './nativeFilters';
+import { SaveFilterChangesType } from '../components/nativeFilters/FiltersConfigModal/types';
-const createUpdateDashboardApi = (id: number) =>
+const createUpdateChartCustomizationsApi = (id: number) =>
makeApi<
- Partial<DashboardInfo>,
- { result: Partial<DashboardInfo>; last_modified_time: number }
+ {
+ modified: (
+ | (ChartCustomization & { cascadeParentIds: string[] })
+ | ChartCustomizationDivider
+ )[];
+ deleted: string[];
+ reordered?: string[];
+ },
+ { result: (ChartCustomization | ChartCustomizationDivider)[] }
>({
method: 'PUT',
- endpoint: `/api/v1/dashboard/${id}`,
+ endpoint: `/api/v1/dashboard/${id}/chart_customizations`,
});
-export interface ChartCustomizationSavePayload {
- id: string;
- title?: string;
- description?: string;
- removed?: boolean;
- chartId?: number;
- customization: {
- name: string;
- dataset:
- | string
- | number
- | {
- value: string | number;
- label?: string;
- table_name?: string;
- schema?: string;
- }
- | null;
- datasetInfo?: {
- label: string;
- value: number;
- table_name: string;
- };
- column: string | string[] | null;
- description?: string;
- sortFilter?: boolean;
- sortAscending?: boolean;
- sortMetric?: string;
- hasDefaultValue?: boolean;
- defaultValue?: string;
- isRequired?: boolean;
- selectFirst?: boolean;
- defaultDataMask?: DataMask;
- defaultValueQueriesData?: ColumnOption[] | null;
- aggregation?: string;
- canSelectMultiple?: boolean;
- };
-}
-
export const SAVE_CHART_CUSTOMIZATION_COMPLETE =
'SAVE_CHART_CUSTOMIZATION_COMPLETE';
export function setChartCustomization(
- chartCustomization: ChartCustomizationItem[],
+ chartCustomization: ChartCustomization[],
) {
return { type: SAVE_CHART_CUSTOMIZATION_COMPLETE, chartCustomization };
}
export function saveChartCustomization(
- chartCustomizationItems: ChartCustomizationSavePayload[],
+ modifiedCustomizations: (ChartCustomization | ChartCustomizationDivider)[],
+ deletedIds: string[],
+ reorderedIds: string[] = [],
+ resetDataMask: boolean = false,
): ThunkAction<
Promise<{ result: Partial<DashboardInfo>; last_modified_time: number }>,
RootState,
@@ -100,119 +81,97 @@ export function saveChartCustomization(
dispatch: ThunkDispatch<RootState, null, AnyAction>,
getState: () => RootState,
) {
- const { id, metadata, json_metadata } = getState().dashboardInfo;
-
- const currentState = getState();
- const currentChartCustomizationItems =
- currentState.dashboardInfo.metadata?.chart_customization_config || [];
-
- const existingItemsMap = new Map(
- currentChartCustomizationItems.map(item => [item.id, item]),
- );
-
- const updatedItemsMap = new Map(existingItemsMap);
-
- chartCustomizationItems.forEach(newItem => {
- if (newItem.removed) {
- updatedItemsMap.delete(newItem.id);
- } else {
- const chartCustomizationItem: ChartCustomizationItem = {
- id: newItem.id,
- title: newItem.title,
- removed: newItem.removed,
- chartId: newItem.chartId,
- customization: newItem.customization,
- };
- updatedItemsMap.set(newItem.id, chartCustomizationItem);
+ const { id, metadata } = getState().dashboardInfo;
+
+ const modifiedItems = modifiedCustomizations.map(item => {
+ if ('cascadeParentIds' in item) {
+ return {
+ ...item,
+ cascadeParentIds: item.cascadeParentIds || [],
+ } as ChartCustomization & { cascadeParentIds: string[] };
}
+ return item as ChartCustomizationDivider;
});
- const simpleItems = Array.from(updatedItemsMap.values());
-
- dispatch(setChartCustomization(simpleItems));
-
- const removedItems = currentChartCustomizationItems.filter(
- existingItem => !updatedItemsMap.has(existingItem.id),
- );
-
- removedItems.forEach(removedItem => {
- const customizationFilterId = `chart_customization_${removedItem.id}`;
- dispatch(removeDataMask(customizationFilterId));
+ deletedIds.forEach((customizationId: string) => {
+ dispatch(removeDataMask(customizationId));
});
- simpleItems.forEach(item => {
- const customizationFilterId = `chart_customization_${item.id}`;
-
- if (item.customization?.column) {
- const existingDataMask = getState().dataMask[customizationFilterId];
-
- const existingFilterState = existingDataMask?.filterState;
-
- dispatch(removeDataMask(customizationFilterId));
-
- const dataMask = {
- extraFormData: {},
- filterState: {
- value:
- existingFilterState?.value ||
- item.customization?.defaultDataMask?.filterState?.value ||
- [],
- },
- ownState: {
- column: item.customization.column,
- },
- };
-
- dispatch(updateDataMask(customizationFilterId, dataMask));
- } else {
- dispatch(removeDataMask(customizationFilterId));
- }
- });
-
- const updateDashboard = createUpdateDashboardApi(id);
+ const updateChartCustomizations = createUpdateChartCustomizationsApi(id);
try {
- let parsedMetadata: any = {};
- try {
- parsedMetadata = json_metadata ? JSON.parse(json_metadata) : metadata;
- } catch (e) {
- console.error('Error parsing json_metadata:', e);
- parsedMetadata = metadata || {};
- }
+ const response = await updateChartCustomizations({
+ modified: modifiedItems,
+ deleted: deletedIds,
+ reordered: reorderedIds,
+ });
- const updatedMetadata = {
- ...parsedMetadata,
- native_filter_configuration: (
- parsedMetadata.native_filter_configuration || []
- ).filter(
- (item: any) =>
- !(
- item.type === 'CHART_CUSTOMIZATION' &&
- item.id === 'chart_customization_groupby'
- ),
- ),
- chart_customization_config: simpleItems,
- };
+ const currentMetadata = getState().dashboardInfo.metadata;
+ const currentConfig = currentMetadata?.chart_customization_config || [];
+
+ const mergedResult = response.result.map(
+ (item: ChartCustomization | ChartCustomizationDivider) => {
+ const existing = currentConfig.find(
+ (c: ChartCustomization | ChartCustomizationDivider) =>
+ c.id === item.id,
+ );
+ if (!existing) {
+ return item;
+ }
+ return {
+ ...item,
+ chartsInScope:
+ (item as ChartCustomization).chartsInScope ??
+ (existing as ChartCustomization).chartsInScope,
+ tabsInScope:
+ (item as ChartCustomization).tabsInScope ??
+ (existing as ChartCustomization).tabsInScope,
+ scope:
+ (item as ChartCustomization).scope ??
+ (existing as ChartCustomization).scope,
+ };
+ },
+ );
- const response = await updateDashboard({
- json_metadata: JSON.stringify(updatedMetadata),
+ dispatch({
+ type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
+ filterChanges: mergedResult,
});
- const lastModifiedTime = response.last_modified_time;
+ dispatch(
+ dashboardInfoChanged({
+ metadata: {
+ ...currentMetadata,
+ chart_customization_config: mergedResult,
+ },
+ }),
+ );
- if (lastModifiedTime) {
- dispatch(onSave(lastModifiedTime));
- }
+ if (resetDataMask) {
+ const oldConfig = metadata?.chart_customization_config || [];
+ const oldCustomizationsById = oldConfig.reduce<
+ Record<string, ChartCustomization | ChartCustomizationDivider>
+ >((acc, customization) => {
+ acc[customization.id] = customization;
+ return acc;
+ }, {});
+
+ const customizationFilterChanges: SaveFilterChangesType = {
+ modified: modifiedCustomizations as unknown as Filter[],
+ deleted: deletedIds,
+ reordered: reorderedIds,
+ };
- const { dashboardState } = getState();
- const chartIds = dashboardState.sliceIds || [];
- if (chartIds.length > 0) {
- chartIds.forEach(chartId => {
- dispatch(triggerQuery(true, chartId));
- });
+ dispatch(
+ setDataMaskForFilterChangesComplete(
+ customizationFilterChanges,
+ oldCustomizationsById as unknown as Filters,
+ true,
+ ),
+ );
}
- return response;
+ return { result: {}, last_modified_time: Date.now() };
} catch (errorObject) {
const { error } = await getClientErrorObject(errorObject);
dispatch(
@@ -223,45 +182,6 @@ export function saveChartCustomization(
};
}
-export const INITIALIZE_CHART_CUSTOMIZATION = 'INITIALIZE_CHART_CUSTOMIZATION';
-export interface InitializeChartCustomization {
- type: typeof INITIALIZE_CHART_CUSTOMIZATION;
- chartCustomizationItems: ChartCustomizationItem[];
-}
-
-export function initializeChartCustomization(
- chartCustomizationItems: ChartCustomizationItem[],
-): ThunkAction<void, RootState, null, AnyAction> {
- return (dispatch: ThunkDispatch<RootState, null, AnyAction>) => {
- dispatch({
- type: INITIALIZE_CHART_CUSTOMIZATION,
- chartCustomizationItems,
- });
-
- chartCustomizationItems.forEach(item => {
- const customizationFilterId = `chart_customization_${item.id}`;
-
- if (item.customization?.column) {
- dispatch(removeDataMask(customizationFilterId));
-
- const dataMask = {
- extraFormData: {},
- filterState: {
- value:
- item.customization?.defaultDataMask?.filterState?.value || [],
- },
- ownState: {
- column: item.customization.column,
- },
- };
- dispatch(updateDataMask(customizationFilterId, dataMask));
- } else {
- dispatch(removeDataMask(customizationFilterId));
- }
- });
- };
-}
-
export const SET_CHART_CUSTOMIZATION_DATA_LOADING =
'SET_CHART_CUSTOMIZATION_DATA_LOADING';
export interface SetChartCustomizationDataLoading {
@@ -285,12 +205,12 @@ export const SET_CHART_CUSTOMIZATION_DATA = 'SET_CHART_CUSTOMIZATION_DATA';
export interface SetChartCustomizationData {
type: typeof SET_CHART_CUSTOMIZATION_DATA;
itemId: string;
- data: FilterOption[];
+ data: ColumnOption[];
}
export function setChartCustomizationData(
itemId: string,
- data: FilterOption[],
+ data: ColumnOption[],
): SetChartCustomizationData {
return {
type: SET_CHART_CUSTOMIZATION_DATA,
@@ -317,7 +237,7 @@ export function loadChartCustomizationData(
return;
}
- dispatch(setChartCustomizationDataLoading(itemId, false));
+ dispatch(setChartCustomizationDataLoading(itemId, true));
};
}
@@ -325,11 +245,11 @@ export const SET_PENDING_CHART_CUSTOMIZATION =
'SET_PENDING_CHART_CUSTOMIZATION';
export interface SetPendingChartCustomization {
type: typeof SET_PENDING_CHART_CUSTOMIZATION;
- pendingCustomization: ChartCustomizationSavePayload;
+ pendingCustomization: ChartCustomization;
}
export function setPendingChartCustomization(
- pendingCustomization: ChartCustomizationSavePayload,
+ pendingCustomization: ChartCustomization,
): SetPendingChartCustomization {
return {
type: SET_PENDING_CHART_CUSTOMIZATION,
@@ -380,9 +300,75 @@ export function clearAllChartCustomizationsFromMetadata() {
return clearAllChartCustomizations();
}
+export function setInScopeStatusOfCustomizations(
+ customizationScopes: {
+ customizationId: string;
+ chartsInScope: number[];
+ tabsInScope: string[];
+ }[],
+): ThunkAction<void, RootState, null, AnyAction> {
+ return (
+ dispatch: ThunkDispatch<RootState, null, AnyAction>,
+ getState: () => RootState,
+ ) => {
+ const { filters } = getState().nativeFilters;
+
+ const scopeConfig = customizationScopes
+ .map(({ customizationId, chartsInScope, tabsInScope }) => {
+ const existing = filters[customizationId];
+ if (!existing) return null;
+ return {
+ ...existing,
+ chartsInScope,
+ tabsInScope,
+ };
+ })
+ .filter(Boolean);
+
+ if (scopeConfig.length > 0) {
+ dispatch({
+ type: SET_IN_SCOPE_STATUS_OF_FILTERS,
+ filterConfig: scopeConfig,
+ });
+ }
+
+ const { metadata } = getState().dashboardInfo;
+ const customizationConfig = metadata?.chart_customization_config || [];
+
+ const scopeMap = new Map(
+ customizationScopes.map(
+ ({ customizationId, chartsInScope, tabsInScope }) => [
+ customizationId,
+ { chartsInScope, tabsInScope },
+ ],
+ ),
+ );
+
+ const updatedConfig = customizationConfig.map(customization => {
+ const scope = scopeMap.get(customization.id);
+ if (!scope) {
+ return customization;
+ }
+ return {
+ ...customization,
+ chartsInScope: scope.chartsInScope,
+ tabsInScope: scope.tabsInScope,
+ };
+ });
+
+ dispatch(
+ dashboardInfoChanged({
+ metadata: {
+ ...metadata,
+ chart_customization_config: updatedConfig,
+ },
+ }),
+ );
+ };
+}
+
export type AnyChartCustomizationAction =
| ReturnType<typeof setChartCustomization>
- | InitializeChartCustomization
| SetChartCustomizationDataLoading
| SetChartCustomizationData
| SetPendingChartCustomization
diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js
index b6f11988e1..0c545e2240 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.js
+++ b/superset-frontend/src/dashboard/actions/hydrate.js
@@ -225,8 +225,12 @@ export const hydrateDashboard =
directPathToChild.push(directLinkComponentId);
}
+ const chartCustomizations = metadata?.chart_customization_config || [];
+ const filters = metadata?.native_filter_configuration || [];
+ const combinedFilters = [...filters, ...chartCustomizations];
+
const nativeFilters = getInitialNativeFilterState({
- filterConfig: metadata?.native_filter_configuration || [],
+ filterConfig: combinedFilters,
});
const { chartConfiguration, globalChartConfiguration } =
@@ -244,8 +248,6 @@ export const hydrateDashboard =
metadata.cross_filters_enabled,
);
- const chartCustomizationItems = metadata?.chart_customization_config || [];
-
return dispatch({
type: HYDRATE_DASHBOARD,
data: {
@@ -311,7 +313,6 @@ export const hydrateDashboard =
datasetsStatus:
dashboardState?.datasetsStatus || ResourceStatus.Loading,
chartStates: chartStates || dashboardState?.chartStates || {},
- chartCustomizationItems,
},
dashboardLayout,
},
diff --git a/superset-frontend/src/dashboard/actions/nativeFilters.ts b/superset-frontend/src/dashboard/actions/nativeFilters.ts
index f9d93aa226..4a628af3f4 100644
--- a/superset-frontend/src/dashboard/actions/nativeFilters.ts
+++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts
@@ -23,6 +23,7 @@ import {
makeApi,
} from '@superset-ui/core';
import { Dispatch } from 'redux';
+import { RootState } from 'src/dashboard/types';
import { cloneDeep } from 'lodash';
import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions';
import { HYDRATE_DASHBOARD } from './hydrate';
@@ -64,7 +65,7 @@ const isFilterChangesEmpty = (filterChanges: SaveFilterChangesType) =>
export const setFilterConfiguration =
(filterChanges: SaveFilterChangesType) =>
- async (dispatch: Dispatch, getState: () => any) => {
+ async (dispatch: Dispatch, getState: () => RootState) => {
if (isFilterChangesEmpty(filterChanges)) {
return;
}
@@ -77,20 +78,17 @@ export const setFilterConfiguration =
filterChanges,
});
- const updateFilters = makeApi<
- SaveFilterChangesType,
- { result: SaveFilterChangesType }
- >({
+ const updateFilters = makeApi<SaveFilterChangesType, { result: Filter[] }>({
method: 'PUT',
endpoint: `/api/v1/dashboard/${id}/filters`,
});
try {
const response = await updateFilters(filterChanges);
- dispatch(nativeFiltersConfigChanged(response.result));
dispatch({
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: response.result,
});
+ dispatch(nativeFiltersConfigChanged(response.result));
dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters));
} catch (err) {
dispatch({
@@ -108,7 +106,7 @@ export const setInScopeStatusOfFilters =
tabsInScope: string[];
}[],
) =>
- async (dispatch: Dispatch, getState: () => any) => {
+ async (dispatch: Dispatch, getState: () => RootState) => {
const filters = getState().nativeFilters?.filters;
const filtersWithScopes = filterScopes.map(scope => ({
...filters[scope.filterId],
@@ -119,10 +117,9 @@ export const setInScopeStatusOfFilters =
type: SET_IN_SCOPE_STATUS_OF_FILTERS,
filterConfig: filtersWithScopes,
});
- // need to update native_filter_configuration in the dashboard metadata
const metadata = cloneDeep(getState().dashboardInfo.metadata);
- const filterConfig: FilterConfiguration =
- metadata.native_filter_configuration || [];
+ const filterConfig =
+ (metadata.native_filter_configuration as FilterConfiguration) || [];
const mergedFilterConfig = filterConfig.map(filter => {
const filterWithScope = filtersWithScopes.find(
scope => scope.id === filter.id,
@@ -130,7 +127,11 @@ export const setInScopeStatusOfFilters =
if (!filterWithScope) {
return filter;
}
- return { ...filterWithScope, ...filter };
+ return {
+ ...filter,
+ chartsInScope: filterWithScope.chartsInScope,
+ tabsInScope: filterWithScope.tabsInScope,
+ };
});
metadata.native_filter_configuration = mergedFilterConfig;
dispatch(
diff --git a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx b/superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx
similarity index 55%
rename from superset-frontend/src/dashboard/components/GroupByBadge/index.tsx
rename to superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx
index ad44c17d64..6f7fc988b5 100644
--- a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx
+++ b/superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx
@@ -20,13 +20,27 @@ import { memo, useMemo, useState, useRef } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { t } from '@apache-superset/core';
+import { ChartCustomization, DataMaskStateWithId } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Icons, Badge, Tooltip, Tag } from '@superset-ui/core/components';
import { getFilterValueForDisplay } from '../nativeFilters/utils';
-import { ChartCustomizationItem } from '../nativeFilters/ChartCustomization/types';
+import { extractLabel } from '../nativeFilters/selectors';
+import { useChartCustomizationFromRedux } from '../nativeFilters/state';
import { RootState } from '../../types';
import { isChartWithoutGroupBy } from '../../util/charts/chartTypeLimitations';
+const getCustomizationDataset = (
+ item: ChartCustomization | any,
+): string | number | null => {
+ if (item.targets?.[0]?.datasetId !== undefined) {
+ return item.targets[0].datasetId;
+ }
+ if (item.customization?.dataset !== undefined) {
+ return item.customization.dataset;
+ }
+ return null;
+};
+
const makeSelectChartDataset = (chartId: number) =>
createSelector(
(state: RootState) => state.charts[chartId]?.latestQueryFormData,
@@ -47,7 +61,7 @@ const makeSelectChartFormData = (chartId: number) =>
latestQueryFormData => latestQueryFormData,
);
-export interface GroupByBadgeProps {
+export interface CustomizationsBadgeProps {
chartId: number;
}
@@ -150,17 +164,15 @@ const GroupByValue = styled.span`
overflow: auto;
`;
-export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
+export const CustomizationsBadge = ({ chartId }: CustomizationsBadgeProps) => {
const [tooltipVisible, setTooltipVisible] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
- const chartCustomizationItems = useSelector<
- RootState,
- ChartCustomizationItem[]
- >(
- ({ dashboardInfo }) =>
- dashboardInfo.metadata?.chart_customization_config || [],
+ const chartCustomizationItems = useChartCustomizationFromRedux();
+
+ const dataMask = useSelector<RootState, DataMaskStateWithId>(
+ state => state.dataMask,
);
// Use memoized selectors for chart data
@@ -177,28 +189,37 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
const chartFormData = useSelector(selectChartFormData);
const chartType = chartFormData?.viz_type;
- const applicableGroupBys = useMemo(() => {
+ const applicableCustomizations = useMemo(() => {
if (!chartDataset) {
return [];
}
return chartCustomizationItems.filter(item => {
- if (item.removed) return false;
+ if (item.removed) {
+ return false;
+ }
- const targetDataset = item.customization?.dataset;
- if (!targetDataset) return false;
+ if (item.chartsInScope && !item.chartsInScope.includes(chartId)) {
+ return false;
+ }
+
+ const targetDataset = getCustomizationDataset(item);
+ if (!targetDataset) {
+ return false;
+ }
const targetDatasetId = String(targetDataset);
const matchesDataset = chartDataset === targetDatasetId;
- const hasColumn = item.customization?.column;
+ const filterState = dataMask[item.id]?.filterState;
+ const hasValue = extractLabel(filterState) !== null;
- return matchesDataset && hasColumn;
+ return matchesDataset && hasValue;
});
- }, [chartCustomizationItems, chartDataset]);
+ }, [chartCustomizationItems, chartDataset, chartId, dataMask]);
- const effectiveGroupBys = useMemo(() => {
- if (!chartType || applicableGroupBys.length === 0) {
+ const effectiveCustomizations = useMemo(() => {
+ if (!chartType || applicableCustomizations.length === 0) {
return [];
}
@@ -206,129 +227,46 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
return [];
}
- if (!chartFormData) {
- return applicableGroupBys;
- }
-
- const existingColumns = new Set<string>();
-
- const extractColumnNames = (columns: unknown[]): void => {
- if (Array.isArray(columns)) {
- columns.forEach((col: unknown) => {
- if (typeof col === 'string') {
- existingColumns.add(col);
- } else if (col && typeof col === 'object' && 'column_name' in col) {
- existingColumns.add((col as { column_name: string }).column_name);
- }
- });
- }
- };
-
- const existingGroupBy = Array.isArray(chartFormData.groupby)
- ? chartFormData.groupby
- : chartFormData.groupby
- ? [chartFormData.groupby]
- : [];
- existingGroupBy.forEach((col: string) => existingColumns.add(col));
-
- if (chartFormData.x_axis) {
- existingColumns.add(chartFormData.x_axis);
- }
-
- const metrics = chartFormData.metrics || [];
- metrics.forEach((metric: any) => {
- if (typeof metric === 'string') {
- existingColumns.add(metric);
- } else if (metric && typeof metric === 'object' && 'column' in metric) {
- const metricColumn = metric.column;
- if (typeof metricColumn === 'string') {
- existingColumns.add(metricColumn);
- } else if (
- metricColumn &&
- typeof metricColumn === 'object' &&
- 'column_name' in metricColumn
- ) {
- existingColumns.add(metricColumn.column_name);
- }
- }
- });
-
- if (chartFormData.series) {
- existingColumns.add(chartFormData.series);
- }
- if (chartFormData.entity) {
- existingColumns.add(chartFormData.entity);
- }
- if (chartFormData.source) {
- existingColumns.add(chartFormData.source);
- }
- if (chartFormData.target) {
- existingColumns.add(chartFormData.target);
- }
-
- if (chartType === 'pivot_table_v2') {
- extractColumnNames(chartFormData.groupbyColumns || []);
- }
-
- if (chartType === 'box_plot') {
- extractColumnNames(chartFormData.columns || []);
- }
-
- return applicableGroupBys.filter(item => {
- if (!item.customization?.column) return false;
-
- let columnNames: string[] = [];
- if (typeof item.customization.column === 'string') {
- columnNames = [item.customization.column];
- } else if (Array.isArray(item.customization.column)) {
- columnNames = item.customization.column.filter(
- col => typeof col === 'string' && col.trim() !== '',
- );
- } else if (
- typeof item.customization.column === 'object' &&
- item.customization.column !== null
- ) {
- const columnObj = item.customization.column as any;
- const columnName =
- columnObj.column_name || columnObj.name || String(columnObj);
- if (columnName && columnName.trim() !== '') {
- columnNames = [columnName];
- }
- }
-
- return columnNames.length > 0;
+ return applicableCustomizations.filter(customization => {
+ const filterState = dataMask[customization.id]?.filterState;
+ const value = filterState?.value;
+ return value !== null && value !== undefined;
});
- }, [applicableGroupBys, chartType, chartFormData]);
+ }, [applicableCustomizations, chartType, dataMask]);
- const groupByCount = effectiveGroupBys.length;
+ const customizationsCount = effectiveCustomizations.length;
- if (groupByCount === 0) {
+ if (customizationsCount === 0) {
return null;
}
const tooltipContent = (
<TooltipContent>
<div>
<SectionName>
- {t('Chart Customization (%d)', applicableGroupBys.length)}
+ {t('Chart Customization (%d)', effectiveCustomizations.length)}
</SectionName>
<GroupByInfo>
- {effectiveGroupBys.map(groupBy => (
- <GroupByItem key={groupBy.id}>
- <div>
- {groupBy.customization?.name &&
- groupBy.customization?.column ? (
- <>
- <GroupByName>{groupBy.customization.name}: </GroupByName>
- <GroupByValue>
- {getFilterValueForDisplay(groupBy.customization.column)}
- </GroupByValue>
- </>
- ) : (
- groupBy.customization?.name || t('None')
- )}
- </div>
- </GroupByItem>
- ))}
+ {effectiveCustomizations.map(customization => {
+ const filterState = dataMask[customization.id]?.filterState;
+ const displayValue = filterState?.label || filterState?.value;
+
+ return (
+ <GroupByItem key={customization.id}>
+ <div>
+ {customization.name && displayValue ? (
+ <>
+ <GroupByName>{customization.name}: </GroupByName>
+ <GroupByValue>
+ {getFilterValueForDisplay(displayValue)}
+ </GroupByValue>
+ </>
+ ) : (
+ customization.name || t('None')
+ )}
+ </div>
+ </GroupByItem>
+ );
+ })}
</GroupByInfo>
</div>
</TooltipContent>
@@ -353,14 +291,14 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
>
<StyledTag
ref={triggerRef}
- aria-label={t('Group by settings (%s)', groupByCount)}
+ aria-label={t('Chart customizations (%s)', customizationsCount)}
role="button"
tabIndex={0}
>
<Icons.GroupOutlined iconSize="m" />
<StyledBadge
- data-test="applied-groupby-count"
- count={groupByCount}
+ data-test="applied-customizations-count"
+ count={customizationsCount}
showZero={false}
/>
</StyledTag>
@@ -368,4 +306,4 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
);
};
-export default memo(GroupByBadge);
+export default memo(CustomizationsBadge);
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
index 126b25d345..886563472c 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx
@@ -43,7 +43,40 @@ jest.mock('src/dashboard/containers/DashboardGrid', () => ({
default: () => <div data-test="mock-dashboard-grid" />,
}));
-function createTestState(overrides = {}) {
+const defaultTestFilter = {
+ id: 'FILTER-1',
+ name: 'Test Filter',
+ filterType: 'filter_select',
+ targets: [
+ {
+ datasetId: 1,
+ column: { name: 'country' },
+ },
+ ],
+ defaultDataMask: {
+ filterState: { value: null },
+ },
+ cascadeParentIds: [],
+ scope: {
+ rootPath: ['ROOT_ID'],
+ excluded: [],
+ },
+ controlValues: {},
+ type: NativeFilterType.NativeFilter,
+};
+
+function createTestState(overrides: Record<string, unknown> = {}) {
+ const nativeFilterConfig = (
+ overrides.dashboardInfo as
+ | { metadata?: { native_filter_configuration?: unknown[] } }
+ | undefined
+ )?.metadata?.native_filter_configuration ?? [defaultTestFilter];
+
+ const nativeFiltersMap: Record<string, unknown> = {};
+ (nativeFilterConfig as Array<{ id: string }>).forEach(filter => {
+ nativeFiltersMap[filter.id] = filter;
+ });
+
return {
...mockState,
dashboardState: {
@@ -66,31 +99,16 @@ function createTestState(overrides = {}) {
},
},
},
- nativeFilters: {
- filters: {
- 'FILTER-1': {
- id: 'FILTER-1',
- name: 'Test Filter',
- filterType: 'filter_select',
- targets: [
- {
- datasetId: 1,
- column: { name: 'country' },
- },
- ],
- defaultDataMask: {
- filterState: { value: null },
- },
- cascadeParentIds: [],
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [],
- },
- controlValues: {},
- type: NativeFilterType.NativeFilter,
- },
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ metadata: {
+ ...mockState.dashboardInfo.metadata,
+ native_filter_configuration: nativeFilterConfig,
},
},
+ nativeFilters: {
+ filters: nativeFiltersMap,
+ },
...overrides,
};
}
@@ -114,13 +132,17 @@ function setupWithStore(overrideState = {}) {
}
let setInScopeStatusMock: jest.SpyInstance;
+const originalSetInScopeStatus = nativeFiltersActions.setInScopeStatusOfFilters;
beforeEach(() => {
setInScopeStatusMock = jest.spyOn(
nativeFiltersActions,
'setInScopeStatusOfFilters',
);
- setInScopeStatusMock.mockReturnValue(jest.fn());
+ setInScopeStatusMock.mockImplementation(args => {
+ const thunk = originalSetInScopeStatus(args);
+ return thunk;
+ });
});
afterEach(() => {
@@ -144,35 +166,37 @@ test('calculates chartsInScope correctly for filters', async () => {
);
});
-test('recalculates chartsInScope when filter non-scope properties change', async () => {
+test('preserves chartsInScope when filter non-scope properties change', async () => {
const { store } = setupWithStore();
await waitFor(() => {
expect(setInScopeStatusMock).toHaveBeenCalled();
});
- setInScopeStatusMock.mockClear();
+ const stateBeforeUpdate = store.getState();
+ const filterBeforeUpdate =
+ stateBeforeUpdate.nativeFilters.filters['FILTER-1'];
+
+ expect(filterBeforeUpdate.chartsInScope).toEqual([sliceId]);
- // Bug scenario: Editing non-scope properties (e.g., "Sort filter values")
- // triggers backend save, but response lacks chartsInScope.
- // The fix ensures useEffect recalculates chartsInScope anyway.
- const initialState = store.getState();
store.dispatch({
type: 'SET_NATIVE_FILTERS_CONFIG_COMPLETE',
filterChanges: [
{
- ...initialState.nativeFilters.filters['FILTER-1'],
+ ...filterBeforeUpdate,
controlValues: {
- ...initialState.nativeFilters.filters['FILTER-1'].controlValues,
+ ...filterBeforeUpdate.controlValues,
sortAscending: false,
},
},
],
});
- await waitFor(() => {
- expect(setInScopeStatusMock).toHaveBeenCalled();
- });
+ const stateAfterUpdate = store.getState();
+ const filterAfterUpdate = stateAfterUpdate.nativeFilters.filters['FILTER-1'];
+
+ expect(filterAfterUpdate.chartsInScope).toEqual([sliceId]);
+ expect(filterAfterUpdate.controlValues?.sortAscending).toBe(false);
});
test('handles multiple filters with different scopes', async () => {
@@ -180,8 +204,28 @@ test('handles multiple filters with different scopes', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
+ const multipleFilters = [
+ {
+ id: 'FILTER-1',
+ name: 'Filter 1',
+ filterType: 'filter_select',
+ targets: [{ datasetId: 1, column: { name: 'country' } }],
+ scope: { rootPath: ['ROOT_ID'], excluded: [] },
+ controlValues: {},
+ type: NativeFilterType.NativeFilter,
+ },
+ {
+ id: 'FILTER-2',
+ name: 'Filter 2',
+ filterType: 'filter_select',
+ targets: [{ datasetId: 1, column: { name: 'region' } }],
+ scope: { rootPath: ['ROOT_ID'], excluded: [19] },
+ controlValues: {},
+ type: NativeFilterType.NativeFilter,
+ },
+ ];
+
const stateWithMultipleFilters = {
- ...mockState,
dashboardState: {
...mockState.dashboardState,
sliceIds: [18, 19],
@@ -204,26 +248,17 @@ test('handles multiple filters with different scopes', async () => {
},
},
},
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ metadata: {
+ ...mockState.dashboardInfo.metadata,
+ native_filter_configuration: multipleFilters,
+ },
+ },
nativeFilters: {
filters: {
- 'FILTER-1': {
- id: 'FILTER-1',
- name: 'Filter 1',
- filterType: 'filter_select',
- targets: [{ datasetId: 1, column: { name: 'country' } }],
- scope: { rootPath: ['ROOT_ID'], excluded: [] },
- controlValues: {},
- type: NativeFilterType.NativeFilter,
- },
- 'FILTER-2': {
- id: 'FILTER-2',
- name: 'Filter 2',
- filterType: 'filter_select',
- targets: [{ datasetId: 1, column: { name: 'region' } }],
- scope: { rootPath: ['ROOT_ID'], excluded: [19] },
- controlValues: {},
- type: NativeFilterType.NativeFilter,
- },
+ 'FILTER-1': multipleFilters[0],
+ 'FILTER-2': multipleFilters[1],
},
},
};
@@ -250,20 +285,24 @@ test('handles multiple filters with different scopes', async () => {
test('handles filters with no charts in scope', async () => {
const stateWithExcludedFilter = createTestState({
- nativeFilters: {
- filters: {
- 'FILTER-1': {
- id: 'FILTER-1',
- name: 'Excluded Filter',
- filterType: 'filter_select',
- targets: [{ datasetId: 1, column: { name: 'country' } }],
- scope: {
- rootPath: ['ROOT_ID'],
- excluded: [sliceId],
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ metadata: {
+ ...mockState.dashboardInfo.metadata,
+ native_filter_configuration: [
+ {
+ id: 'FILTER-1',
+ name: 'Excluded Filter',
+ filterType: 'filter_select',
+ targets: [{ datasetId: 1, column: { name: 'country' } }],
+ scope: {
+ rootPath: ['ROOT_ID'],
+ excluded: [sliceId],
+ },
+ controlValues: {},
+ type: NativeFilterType.NativeFilter,
},
- controlValues: {},
- type: NativeFilterType.NativeFilter,
- },
+ ],
},
},
});
@@ -286,8 +325,12 @@ test('handles filters with no charts in scope', async () => {
test('does not dispatch when there are no filters', () => {
const stateWithoutFilters = createTestState({
- nativeFilters: {
- filters: {},
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ metadata: {
+ ...mockState.dashboardInfo.metadata,
+ native_filter_configuration: [],
+ },
},
});
@@ -301,8 +344,17 @@ test('calculates tabsInScope for filters with tab-scoped charts', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout;
+ const tabScopedFilter = {
+ id: 'FILTER-TAB-SCOPED',
+ name: 'Tab Scoped Filter',
+ filterType: 'filter_select',
+ targets: [{ datasetId: 1, column: { name: 'region' } }],
+ scope: { rootPath: ['ROOT_ID'], excluded: [22] },
+ controlValues: {},
+ type: NativeFilterType.NativeFilter,
+ };
+
const stateWithTabs = {
- ...mockState,
dashboardState: {
...mockState.dashboardState,
sliceIds: [20, 21, 22],
@@ -356,17 +408,16 @@ test('calculates tabsInScope for filters with tab-scoped charts', async () => {
},
},
},
+ dashboardInfo: {
+ ...mockState.dashboardInfo,
+ metadata: {
+ ...mockState.dashboardInfo.metadata,
+ native_filter_configuration: [tabScopedFilter],
+ },
+ },
nativeFilters: {
filters: {
- 'FILTER-TAB-SCOPED': {
- id: 'FILTER-TAB-SCOPED',
- name: 'Tab Scoped Filter',
- filterType: 'filter_select',
- targets: [{ datasetId: 1, column: { name: 'region' } }],
- scope: { rootPath: ['ROOT_ID'], excluded: [22] },
- controlValues: {},
- type: NativeFilterType.NativeFilter,
- },
+ 'FILTER-TAB-SCOPED': tabScopedFilter,
},
},
};
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx
index 95e4b120b9..d7f0be5274 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx
@@ -27,16 +27,17 @@ import {
useRef,
useState,
} from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
+import { isEqual } from 'lodash';
import {
- Filter,
- Filters,
+ ChartCustomizationConfiguration,
+ ChartCustomizationType,
LabelsColorMapSource,
+ NativeFilterType,
getLabelsColorMap,
} from '@superset-ui/core';
import { ParentSize } from '@visx/responsive';
-import { pick } from 'lodash';
import Tabs from '@superset-ui/core/components/Tabs';
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
import {
@@ -49,9 +50,9 @@ import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_DEPTH,
} from 'src/dashboard/util/constants';
-import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
import { setInScopeStatusOfFilters } from 'src/dashboard/actions/nativeFilters';
+import { setInScopeStatusOfCustomizations } from 'src/dashboard/actions/chartCustomizationActions';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import {
applyDashboardLabelsColorOnLoad,
@@ -60,16 +61,30 @@ import {
ensureSyncedSharedLabelsColors,
ensureSyncedLabelsColorMap,
} from 'src/dashboard/actions/dashboardState';
-import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { getColorNamespace, resetColors } from 'src/utils/colorScheme';
+import { calculateScopes } from 'src/dashboard/util/calculateScopes';
+import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
-import { findTabsWithChartsInScope } from '../nativeFilters/utils';
+import { selectFilterConfiguration } from '../nativeFilters/state';
import { getRootLevelTabsComponent } from './utils';
type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
};
+interface ScopeData {
+ chartsInScope: number[];
+ tabsInScope: string[];
+}
+
+interface FilterScopeData extends ScopeData {
+ filterId: string;
+}
+
+interface CustomizationScopeData extends ScopeData {
+ customizationId: string;
+}
+
export const renderedChartIdsSelector: (state: RootState) => number[] =
createSelector([(state: RootState) => state.charts], charts =>
Object.values(charts)
@@ -80,32 +95,14 @@ export const renderedChartIdsSelector: (state: RootState) => number[] =
const useRenderedChartIds = () => {
const renderedChartIds = useSelector<RootState, number[]>(
renderedChartIdsSelector,
+ shallowEqual,
);
- return useMemo(() => renderedChartIds, [JSON.stringify(renderedChartIds)]);
-};
-
-const useNativeFilterScopes = () => {
- const nativeFilters = useSelector<RootState, Filters>(
- state => state.nativeFilters?.filters,
- );
- return useMemo(
- () =>
- nativeFilters
- ? Object.values(nativeFilters).map((filter: Filter) =>
- pick(filter, ['id', 'scope', 'type']),
- )
- : [],
- [nativeFilters],
- );
+ return renderedChartIds;
};
const TOP_OF_PAGE_RANGE = 220;
const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
- const nativeFilterScopes = useNativeFilterScopes();
- const nativeFilters = useSelector<RootState, Filters>(
- state => state.nativeFilters?.filters,
- );
const dispatch = useDispatch();
const dashboardLayout = useSelector<RootState, DashboardLayout>(
@@ -114,6 +111,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const dashboardInfo = useSelector<RootState, DashboardInfo>(
state => state.dashboardInfo,
);
+ const filterItems = useSelector(selectFilterConfiguration);
+ const chartCustomizations = useSelector<
+ RootState,
+ ChartCustomizationConfiguration
+ >(
+ state => state.dashboardInfo?.metadata?.chart_customization_config || [],
+ shallowEqual,
+ );
const directPathToChild = useSelector<RootState, string[]>(
state => state.dashboardState.directPathToChild,
);
@@ -125,6 +130,8 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
useState(false);
const prevRenderedChartIds = useRef<number[]>([]);
const prevTabIndexRef = useRef<number>();
+ const prevFilterScopesRef = useRef<FilterScopeData[]>([]);
+ const prevCustomizationScopesRef = useRef<CustomizationScopeData[]>([]);
const tabIndex = useMemo(() => {
const nextTabIndex = findTabIndexByComponentId({
currentComponent: getRootLevelTabsComponent(dashboardLayout),
@@ -150,58 +157,57 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
prevRenderedChartIds.current = [];
}, [dashboardInfo?.metadata?.color_namespace, dispatch]);
+ const chartLayoutItems = useMemo(
+ () =>
+ Object.values(dashboardLayout).filter(item => item?.type === CHART_TYPE),
+ [dashboardLayout],
+ );
+
useEffect(() => {
- if (nativeFilterScopes.length === 0) {
+ if (filterItems.length === 0) {
return;
}
- const scopes = nativeFilterScopes.map(filterScope => {
- if (
- filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX) ||
- filterScope.id.startsWith('chart_customization_')
- ) {
- return {
- filterId: filterScope.id,
- tabsInScope: [],
- chartsInScope: [],
- };
- }
-
- const chartLayoutItems = Object.values(dashboardLayout).filter(
- item => item?.type === CHART_TYPE,
- );
- if (!filterScope.scope || !Array.isArray(filterScope.scope.excluded)) {
- return {
- filterId: filterScope.id,
- tabsInScope: [],
- chartsInScope: [],
- };
- }
+ const scopes = calculateScopes(
+ filterItems,
+ chartIds,
+ chartLayoutItems,
+ item =>
+ item.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX) ||
+ item.type === NativeFilterType.Divider,
+ ).map(scope => ({
+ filterId: scope.id,
+ chartsInScope: scope.chartsInScope,
+ tabsInScope: scope.tabsInScope,
+ }));
+
+ if (!isEqual(scopes, prevFilterScopesRef.current)) {
+ prevFilterScopesRef.current = scopes;
+ dispatch(setInScopeStatusOfFilters(scopes));
+ }
+ }, [chartIds, filterItems, chartLayoutItems, dispatch]);
- const chartsInScope: number[] = getChartIdsInFilterScope(
- filterScope.scope,
- chartIds,
- chartLayoutItems,
- );
+ useEffect(() => {
+ if (chartCustomizations.length === 0) {
+ return;
+ }
- const tabsInScope = findTabsWithChartsInScope(
- chartLayoutItems,
- chartsInScope,
- );
- return {
- filterId: filterScope.id,
- tabsInScope: Array.from(tabsInScope),
- chartsInScope,
- };
- });
- dispatch(setInScopeStatusOfFilters(scopes));
- }, [
- chartIds,
- JSON.stringify(nativeFilterScopes),
- dashboardLayout,
- dispatch,
- JSON.stringify(nativeFilters),
- ]);
+ const scopes = calculateScopes(
+ chartCustomizations,
+ chartIds,
+ chartLayoutItems,
+ item => item.type === ChartCustomizationType.Divider,
+ ).map(scope => ({
+ customizationId: scope.id,
+ chartsInScope: scope.chartsInScope,
+ tabsInScope: scope.tabsInScope,
+ }));
+
+ if (!isEqual(scopes, prevCustomizationScopesRef.current)) {
+ prevCustomizationScopesRef.current = scopes;
+ dispatch(setInScopeStatusOfCustomizations(scopes));
+ }
+ }, [chartIds, chartCustomizations, chartLayoutItems, dispatch]);
const childIds: string[] = useMemo(
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
index c7b7a56fdf..a6de9e390c 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts
@@ -26,6 +26,7 @@ import {
useFilters,
useNativeFiltersDataMask,
} from '../nativeFilters/FilterBar/state';
+import { useChartCustomizationFromRedux } from '../nativeFilters/state';
import { toggleNativeFiltersBar } from '../../actions/dashboardState';
export const useNativeFilters = () => {
@@ -46,12 +47,22 @@ export const useNativeFilters = () => {
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const expandFilters = getUrlParam(URL_PARAMS.expandFilters);
+ const chartCustomizations = useChartCustomizationFromRedux();
const nativeFiltersEnabled =
- showNativeFilters && (canEdit || (!canEdit && filterValues.length !== 0));
+ showNativeFilters &&
+ (canEdit ||
+ (!canEdit &&
+ (filterValues.length !== 0 || chartCustomizations.length !== 0)));
const requiredFirstFilter = useMemo(
- () => filterValues.filter(filter => filter.requiredFirst),
+ () =>
+ filterValues.filter(
+ filter =>
+ 'requiredFirst' in filter &&
+ filter.requiredFirst === true &&
+ filter.filterType !== 'filter_time',
+ ),
[filterValues],
);
const dataMask = useNativeFiltersDataMask();
@@ -82,13 +93,21 @@ export const useNativeFilters = () => {
(isFeatureEnabled(FeatureFlag.FilterBarClosedByDefault) &&
expandFilters === null) ||
expandFilters === false ||
- (filterValues.length === 0 && nativeFiltersEnabled)
+ (filterValues.length === 0 &&
+ chartCustomizations.length === 0 &&
+ nativeFiltersEnabled)
) {
dispatch(toggleNativeFiltersBar(false));
} else {
dispatch(toggleNativeFiltersBar(true));
}
- }, [dispatch, filterValues.length, expandFilters, nativeFiltersEnabled]);
+ }, [
+ dispatch,
+ filterValues.length,
+ chartCustomizations.length,
+ expandFilters,
+ nativeFiltersEnabled,
+ ]);
useEffect(() => {
if (showDashboard) {
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index 4b98c0f140..4e76ec6fe6 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -34,7 +34,7 @@ import { useSelector } from 'react-redux';
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
-import GroupByBadge from 'src/dashboard/components/GroupByBadge';
+import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
import { RootState } from 'src/dashboard/types';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
@@ -297,7 +297,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
</Tooltip>
)}
{!uiConfig.hideChartControls && (
- <GroupByBadge chartId={slice.slice_id} />
+ <CustomizationsBadge chartId={slice.slice_id} />
)}
{!uiConfig.hideChartControls && (
diff --git a/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx b/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx
index 51655e057c..bed9965d2c 100644
--- a/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx
+++ b/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx
@@ -72,7 +72,10 @@ const selectDashboardContextForExplore = createSelector(
const nativeFilters = Object.keys(filters).reduce<
Record<string, Pick<Filter | Divider, 'chartsInScope'>>
>((acc, key) => {
- acc[key] = pick(filters[key], ['chartsInScope']);
+ const filter = filters[key];
+ if ('chartsInScope' in filter) {
+ acc[key] = pick(filter, ['chartsInScope']);
+ }
return acc;
}, {});
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
index ebd07fbf17..9a429f5d22 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx
@@ -70,6 +70,7 @@ import {
getAppliedFilterValues,
} from '../../../util/activeDashboardFilters';
import getFormDataWithExtraFilters from '../../../util/charts/getFormDataWithExtraFilters';
+import { useChartCustomizationFromRedux } from '../../nativeFilters/state';
import { PLACEHOLDER_DATASOURCE } from '../../../constants';
const propTypes = {
@@ -360,10 +361,7 @@ const Chart = props => {
const chartConfiguration = useSelector(
state => state.dashboardInfo.metadata?.chart_configuration,
);
- const chartCustomizationItems = useSelector(
- state =>
- state.dashboardInfo.metadata?.chart_customization_config || EMPTY_ARRAY,
- );
+ const chartCustomizationItems = useChartCustomizationFromRedux();
const colorScheme = useSelector(state => state.dashboardState.colorScheme);
const colorNamespace = useSelector(
state => state.dashboardState.colorNamespace,
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx
deleted file mode 100644
index 277ad9e6c7..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx
+++ /dev/null
@@ -1,1457 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import {
- FC,
- useEffect,
- useMemo,
- useState,
- useRef,
- useCallback,
- ReactNode,
-} from 'react';
-import { useSelector } from 'react-redux';
-import { t } from '@apache-superset/core';
-import { styled, css, useTheme } from '@apache-superset/core/ui';
-import { debounce } from 'lodash';
-import { DatasourcesState, ChartsState, RootState } from 'src/dashboard/types';
-import {
- Constants,
- FormItem,
- Input,
- Select,
- Collapse,
- InfoTooltip,
- Loading,
- Radio,
- type SelectValue,
- FormInstance,
- Checkbox,
- CheckboxChangeEvent,
-} from '@superset-ui/core/components';
-import { DatasetSelectLabel } from 'src/features/datasets/DatasetSelectLabel';
-import { CollapsibleControl } from '../FiltersConfigModal/FiltersConfigForm/CollapsibleControl';
-import DatasetSelect from '../FiltersConfigModal/FiltersConfigForm/DatasetSelect';
-import { mostUsedDataset } from '../FiltersConfigModal/FiltersConfigForm/utils';
-import { ChartCustomizationItem } from './types';
-import { selectChartCustomizationItems } from './selectors';
-
-const { TextArea } = Input;
-
-interface Metric {
- metric_name: string;
- verbose_name?: string;
-}
-
-interface DatasetDetails {
- id: number;
- table_name: string;
- schema?: string;
- database?: { database_name: string };
-}
-
-interface ApiError {
- message?: string;
- error?: string;
-}
-
-interface DatasetColumn {
- column_name?: string;
- name?: string;
- verbose_name?: string;
- filterable?: boolean;
-}
-
-interface DatasetData {
- id: number;
- table_name: string;
- schema?: string;
- database?: { database_name: string };
- metrics?: Metric[];
- columns?: DatasetColumn[];
-}
-
-interface CachedDataset {
- data: DatasetData;
- timestamp: number;
-}
-
-interface ColumnOption {
- label: string;
- value: string;
-}
-
-interface Props {
- form: FormInstance<Record<string, unknown>>;
- item: ChartCustomizationItem;
- onUpdate: (updatedItem: ChartCustomizationItem) => void;
- removedItems: Record<string, { isPending: boolean; timerId?: number } | null>;
- allItems?: ChartCustomizationItem[];
-}
-
-const datasetCache = new Map<number, CachedDataset>();
-
-const CACHE_TTL = 5 * 60 * 1000;
-
-function getCachedDataset(datasetId: number): DatasetData | null {
- const cached = datasetCache.get(datasetId);
- if (!cached) return null;
-
- if (Date.now() - cached.timestamp > CACHE_TTL) {
- datasetCache.delete(datasetId);
- return null;
- }
-
- return cached.data;
-}
-
-function setCachedDataset(datasetId: number, data: DatasetData): void {
- datasetCache.set(datasetId, {
- data,
- timestamp: Date.now(),
- });
-}
-
-const StyledContainer = styled.div`
- ${({ theme }) => `
- display: flex;
- flex-direction: row;
- gap: ${theme.sizeUnit * 4}px;
- padding: ${theme.sizeUnit * 2}px;
- `}
-`;
-
-const FORM_ITEM_WIDTH = 300;
-
-const StyledFormItem = styled(FormItem)`
- ${({ theme }) => `
- width: ${FORM_ITEM_WIDTH}px;
- margin-bottom: ${theme.sizeUnit * 4}px;
- `}
-`;
-
-const CheckboxLabel = styled.span`
- ${({ theme }) => `
- font-size: ${theme.fontSizeSM}px;
- color: ${theme.colorTextSecondary};
- `}
-`;
-
-const StyledRadioGroup = styled(Radio.Group)`
- .ant-radio-wrapper {
- font-size: ${({ theme }) => theme.fontSizeSM}px;
- }
-`;
-
-const StyledMarginTop = styled.div`
- margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
-`;
-
-const ChartCustomizationForm: FC<Props> = ({
- form,
- item,
- onUpdate,
- removedItems,
- allItems,
-}) => {
- const theme = useTheme();
- const customization = useMemo(
- () => item.customization || {},
- [item.customization],
- );
-
- const isRemoved = !!removedItems[item.id];
-
- const loadedDatasets = useSelector<RootState, DatasourcesState>(
- ({ datasources }) => datasources,
- );
- const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
- const globalChartCustomizationItems = useSelector(
- selectChartCustomizationItems,
- );
-
- const chartCustomizationItems = allItems || globalChartCustomizationItems;
-
- const [metrics, setMetrics] = useState<Metric[]>([]);
- const [isDefaultValueLoading, setIsDefaultValueLoading] = useState(false);
- const [error, setError] = useState<ApiError | null>(null);
- const [datasetDetails, setDatasetDetails] = useState<DatasetDetails | null>(
- null,
- );
- const [hasDefaultValue, setHasDefaultValue] = useState(
- customization.hasDefaultValue ?? false,
- );
- const [isRequired, setIsRequired] = useState(
- customization.isRequired ?? false,
- );
- const [selectFirst, setSelectFirst] = useState(
- customization.selectFirst ?? false,
- );
-
- const [canSelectMultiple, setCanSelectMultiple] = useState(
- customization.canSelectMultiple ?? true,
- );
-
- const fetchedRef = useRef({
- dataset: null,
- column: null,
- hasDefaultValue: false,
- defaultValueDataFetched: false,
- });
-
- const getDatasetId = useCallback(
- (
- dataset:
- | string
- | number
- | { value: string | number }
- | { id: string | number }
- | null,
- ): number | null => {
- if (!dataset) return null;
-
- if (typeof dataset === 'number') return dataset;
- if (typeof dataset === 'string') {
- const id = Number(dataset);
- return Number.isNaN(id) ? null : id;
- }
- if (
- typeof dataset === 'object' &&
- dataset !== null &&
- 'value' in dataset
- ) {
- const id = Number(dataset.value);
- return Number.isNaN(id) ? null : id;
- }
- if (typeof dataset === 'object' && dataset !== null && 'id' in dataset) {
- const id = Number(dataset.id);
- return Number.isNaN(id) ? null : id;
- }
-
- return null;
- },
- [],
- );
-
- const getFormValues = useCallback(
- () => form.getFieldValue('filters')?.[item.id] || {},
- [form, item.id],
- );
-
- const excludeDatasetIds = useMemo(() => {
- const usedIds: number[] = [];
-
- chartCustomizationItems.forEach(customItem => {
- if (customItem.id === item.id || customItem.removed) {
- return;
- }
-
- const { dataset } = customItem.customization;
- const datasetId = getDatasetId(dataset);
- if (datasetId !== null) {
- usedIds.push(datasetId);
- }
- });
-
- return usedIds;
- }, [chartCustomizationItems, item.id, getDatasetId]);
-
- const datasetValue = useMemo(() => {
- const datasetId = getDatasetId(customization.dataset);
-
- if (!datasetId) {
- return null;
- }
-
- const loadedDataset = Object.values(loadedDatasets).find(
- dataset => dataset.id === Number(datasetId),
- );
-
- if (loadedDataset) {
- return {
- value: datasetId,
- label: DatasetSelectLabel({
- id: Number(datasetId),
- table_name: loadedDataset.table_name || '',
- schema: loadedDataset.schema || '',
- database: {
- database_name:
- (loadedDataset.database?.database_name as string) ||
- (loadedDataset.database?.name as string) ||
- '',
- },
- }),
- table_name: loadedDataset.table_name,
- schema: loadedDataset.schema,
- };
- }
-
- if (datasetDetails && datasetDetails.id === datasetId) {
- return {
- value: datasetId,
- label: DatasetSelectLabel({
- id: Number(datasetId),
- table_name: datasetDetails.table_name || '',
- schema: datasetDetails.schema || '',
- database: {
- database_name:
- (datasetDetails.database?.database_name as string) || '',
- },
- }),
- table_name: datasetDetails.table_name,
- schema: datasetDetails.schema,
- };
- }
-
- if (customization.datasetInfo) {
- const datasetInfo = customization.datasetInfo as {
- value: number;
- label: string;
- table_name: string;
- schema?: string;
- };
- return {
- value: datasetId,
- label: datasetInfo.label,
- table_name: datasetInfo.table_name,
- schema: datasetInfo.schema,
- };
- }
-
- return {
- value: datasetId,
- label: `Dataset ${datasetId}`,
- };
- }, [
- customization.dataset,
- customization.datasetInfo,
- datasetDetails,
- loadedDatasets,
- getDatasetId,
- ]);
-
- const formChanged = useCallback(() => {
- form.setFields([{ name: 'changed', value: true }]);
-
- const formValues = form.getFieldValue('filters')?.[item.id] || {};
- onUpdate({
- ...item,
- customization: {
- ...customization,
- ...formValues,
- },
- });
- }, [form, item, customization, onUpdate]);
-
- const debouncedFormChanged = useMemo(
- () => debounce(formChanged, Constants.SLOW_DEBOUNCE),
- [formChanged],
- );
-
- const setFormFieldValues = useCallback(
- (values: object) => {
- const currentFilters = form.getFieldValue('filters') || {};
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {
- ...currentFilters[item.id],
- ...values,
- },
- },
- });
- },
- [form, item.id],
- );
-
- const setChartCustomizationFieldValues = useCallback(
- (itemId: string, values: Record<string, unknown>) => {
- const currentFilters = form.getFieldValue('filters') || {};
- const currentItem = currentFilters[itemId] || {};
-
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [itemId]: {
- ...currentItem,
- ...values,
- },
- },
- });
- },
- [form],
- );
-
- const ensureFilterSlot = useCallback(() => {
- const currentFilters = form.getFieldValue('filters') || {};
- if (!currentFilters[item.id]) {
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {},
- },
- });
- }
- }, [form, item.id]);
-
- const fetchDatasetInfo = useCallback(async () => {
- const formValues = getFormValues();
- const dataset = formValues.dataset || customization.dataset;
-
- if (!dataset) {
- setMetrics([]);
- return;
- }
-
- try {
- const datasetId = getDatasetId(dataset);
- if (datasetId === null) return;
-
- const cachedData = getCachedDataset(datasetId);
- if (cachedData) {
- const datasetDetails = {
- id: cachedData.id,
- table_name: cachedData.table_name,
- schema: cachedData.schema,
- database: cachedData.database,
- };
-
- setDatasetDetails(datasetDetails);
-
- const currentFilters = form.getFieldValue('filters') || {};
- const currentItemValues = currentFilters[item.id] || {};
-
- if (
- currentItemValues.dataset &&
- typeof currentItemValues.dataset === 'string'
- ) {
- const enhancedDataset = {
- value: Number(currentItemValues.dataset),
- label: cachedData.table_name,
- table_name: cachedData.table_name,
- schema: cachedData.schema,
- };
-
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {
- ...currentItemValues,
- dataset: currentItemValues.dataset,
- datasetInfo: enhancedDataset,
- ...currentItemValues,
- },
- },
- });
- }
-
- if (cachedData.metrics && cachedData.metrics.length > 0) {
- setMetrics(cachedData.metrics);
- } else {
- setMetrics([]);
- }
- return;
- }
-
- const response = await fetch(`/api/v1/dataset/${datasetId}`);
- const data = await response.json();
-
- if (data?.result) {
- setCachedDataset(datasetId, {
- ...data.result,
- metrics: data.result.metrics || [],
- columns: data.result.columns || [],
- });
-
- const datasetDetails = {
- id: data.result.id,
- table_name: data.result.table_name,
- schema: data.result.schema,
- database: data.result.database,
- };
-
- setDatasetDetails(datasetDetails);
-
- const currentFilters = form.getFieldValue('filters') || {};
- const currentItemValues = currentFilters[item.id] || {};
-
- if (
- currentItemValues.dataset &&
- typeof currentItemValues.dataset === 'string'
- ) {
- const enhancedDataset = {
- value: Number(currentItemValues.dataset),
- label: data.result.table_name,
- table_name: data.result.table_name,
- schema: data.result.schema,
- };
-
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {
- ...currentItemValues,
- dataset: currentItemValues.dataset,
- datasetInfo: enhancedDataset,
- ...currentItemValues,
- },
- },
- });
- }
-
- if (data.result.metrics && data.result.metrics.length > 0) {
- setMetrics(data.result.metrics);
- } else {
- setMetrics([]);
- }
- }
- } catch (error) {
- console.error('Error fetching dataset info:', error);
- setMetrics([]);
- }
- }, [form, item.id, customization.dataset, getDatasetId]);
-
- useEffect(() => {
- const formValues = form.getFieldValue('filters')?.[item.id] || {};
- const dataset = formValues.dataset || customization.dataset;
-
- if (dataset) {
- const datasetId = getDatasetId(dataset);
-
- if (datasetId !== null) {
- fetchDatasetInfo();
- }
- }
- }, [customization.dataset, fetchDatasetInfo, getDatasetId]);
-
- const fetchDefaultValueData = useCallback(async () => {
- const formValues = getFormValues();
- const dataset = formValues.dataset || customization.dataset;
-
- if (!dataset) {
- return;
- }
-
- setIsDefaultValueLoading(true);
- try {
- const datasetId =
- typeof dataset === 'object' && dataset !== null
- ? dataset.value
- : getDatasetId(dataset);
- if (datasetId === null) {
- throw new Error('Invalid dataset ID');
- }
-
- let data;
- const cachedData = getCachedDataset(datasetId);
- if (cachedData) {
- data = { result: cachedData };
- } else {
- const response = await fetch(`/api/v1/dataset/${datasetId}`);
- data = await response.json();
- if (data?.result) {
- setCachedDataset(datasetId, {
- ...data.result,
- metrics: data.result.metrics || [],
- columns: data.result.columns || [],
- });
- }
- }
-
- if (!data?.result?.columns) {
- throw new Error('No columns found in dataset');
- }
-
- const columns = data.result.columns
- .filter((col: DatasetColumn) => col.filterable !== false)
- .map((col: DatasetColumn) => ({
- label: col.verbose_name || col.column_name || col.name,
- value: col.column_name || col.name,
- }));
-
- ensureFilterSlot();
- const currentFilters = form.getFieldValue('filters') || {};
-
- const currentFormValues = getFormValues();
- const selectFirstEnabled =
- currentFormValues.selectFirst ?? customization.selectFirst ?? false;
-
- let autoSelectedColumn = null;
- if (selectFirstEnabled && columns.length > 0) {
- autoSelectedColumn = columns[0].value;
- }
-
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {
- ...currentFilters[item.id],
- defaultValueQueriesData: columns,
- filterType: 'filter_select',
- hasDefaultValue: true,
- ...(autoSelectedColumn && { column: autoSelectedColumn }),
- chartConfiguration: {
- tooltip: {
- appendToBody: true,
- confine: true,
- },
- },
- },
- },
- });
-
- onUpdate({
- ...item,
- customization: {
- ...customization,
- defaultValueQueriesData: columns,
- hasDefaultValue:
- formValues.hasDefaultValue ?? customization.hasDefaultValue,
- ...(autoSelectedColumn && { column: autoSelectedColumn }),
- },
- });
-
- setError(null);
- } catch (error) {
- setError(error);
-
- ensureFilterSlot();
- const currentFilters = form.getFieldValue('filters') || {};
-
- form.setFieldsValue({
- filters: {
- ...currentFilters,
- [item.id]: {
- ...currentFilters[item.id],
- defaultValueQueriesData: null,
- hasDefaultValue:
- currentFilters[item.id]?.hasDefaultValue ??
- customization.hasDefaultValue ??
- false,
- },
- },
- });
- } finally {
- setIsDefaultValueLoading(false);
- }
- }, [customization, ensureFilterSlot, form, item, onUpdate, getDatasetId]);
-
- useEffect(() => {
- ensureFilterSlot();
-
- const defaultDataset = customization.dataset
- ? String(getDatasetId(customization.dataset) || customization.dataset)
- : null;
-
- const initialValues = {
- filters: {
- [item.id]: {
- name: customization.name || '',
- description: customization.description || '',
- dataset: defaultDataset,
- column: customization.column || null,
- filterType: 'filter_select',
- sortFilter: customization.sortFilter || false,
- sortAscending: customization.sortAscending !== false,
- sortMetric: customization.sortMetric || null,
- hasDefaultValue: customization.hasDefaultValue || false,
- isRequired: customization.isRequired || false,
- selectFirst: customization.selectFirst || false,
- defaultValue: customization.defaultValue,
- defaultDataMask: customization.defaultDataMask,
- defaultValueQueriesData: customization.defaultValueQueriesData,
- },
- },
- };
-
- form.setFieldsValue(initialValues);
-
- if (customization.dataset || defaultDataset) {
- fetchDatasetInfo();
- }
-
- if (customization.isRequired) {
- setTimeout(() => {
- form
- .validateFields([['filters', item.id, 'isRequired']])
- .catch(() => {});
- }, 0);
- }
- }, [
- item.id,
- fetchDatasetInfo,
- customization,
- form,
- ensureFilterSlot,
- loadedDatasets,
- charts,
- getDatasetId,
- ]);
-
- useEffect(() => {
- const formValues = form.getFieldValue('filters')?.[item.id] || {};
- const hasDataset = !!formValues.dataset;
- const hasColumn = !!formValues.column;
- const hasDefaultValue = !!formValues.hasDefaultValue;
- const isRequired = !!formValues.controlValues?.enableEmptyFilter;
-
- if (hasDataset && fetchedRef.current.dataset !== formValues.dataset) {
- fetchDatasetInfo();
- }
-
- if (isRequired && (!hasDataset || !hasColumn)) {
- setTimeout(() => {
- form
- .validateFields([
- ['filters', item.id, 'controlValues', 'enableEmptyFilter'],
- ])
- .catch(() => {});
- }, 0);
- }
-
- if (
- hasDataset &&
- hasColumn &&
- hasDefaultValue &&
- (fetchedRef.current.dataset !== formValues.dataset ||
- fetchedRef.current.column !== formValues.column ||
- !fetchedRef.current.defaultValueDataFetched)
- ) {
- fetchedRef.current = {
- dataset: formValues.dataset,
- column: formValues.column,
- hasDefaultValue,
- defaultValueDataFetched: true,
- };
-
- fetchDefaultValueData();
- }
- }, [form, item.id, fetchDatasetInfo, fetchDefaultValueData]);
-
- useEffect(() => {
- const formValues = form.getFieldValue('filters')?.[item.id] || {};
- const selectFirst = formValues.selectFirst ?? customization.selectFirst;
-
- if (selectFirst) {
- setHasDefaultValue(false);
- } else {
- setHasDefaultValue(
- formValues.hasDefaultValue ?? customization.hasDefaultValue ?? false,
- );
- if (formValues.isRequired !== undefined) {
- setIsRequired(formValues.isRequired);
- }
- }
-
- setSelectFirst(selectFirst);
- }, [form, item.id, customization.selectFirst, customization.hasDefaultValue]);
-
- const isRequiredValidator = useCallback(
- async (_, enableEmptyFilter) => {
- if (!enableEmptyFilter) {
- return Promise.resolve();
- }
-
- const current = form.getFieldValue(['filters', item.id]) || {};
- if (!current.dataset) {
- return Promise.reject(
- new Error(
- t(
- 'Dataset must be selected when "Dynamic group by value is required" is enabled',
- ),
- ),
- );
- }
-
- return Promise.resolve();
- },
- [form, item.id],
- );
-
- const getDefaultValueTooltip = useCallback(() => {
- if (selectFirst) {
- return t(
- 'Default value set automatically when "Select first filter value by default" is checked',
- );
- }
- if (isRequired) {
- return t(
- 'Default value must be set when "Dynamic group by value is required" is checked',
- );
- }
- if (hasDefaultValue) {
- return t(
- 'Default value must be set when "Dynamic group by has a default value" is checked',
- );
- }
- return t('Set a default value for this filter');
- }, [selectFirst, isRequired, hasDefaultValue]);
-
- const hasAllRequiredFields = useCallback(() => {
- const formValues = form.getFieldValue('filters')?.[item.id] || {};
- const { name = '', dataset } = formValues;
- const nameValue = name || customization.name || '';
-
- const hasExplicitDataset =
- dataset && typeof dataset === 'string' && dataset.trim() !== '';
-
- return !!(nameValue.trim() && hasExplicitDataset);
- }, [form, item.id, customization.name]);
-
- const shouldShowDefaultValue = useCallback(() => {
- const allFieldsFilled = hasAllRequiredFields();
- const isRequiredFromForm = !!form.getFieldValue([
- 'filters',
- item.id,
- 'controlValues',
- 'enableEmptyFilter',
- ]);
-
- if (isRequiredFromForm) {
- return allFieldsFilled && !isDefaultValueLoading;
- }
-
- return hasDefaultValue && allFieldsFilled && !isDefaultValueLoading;
- }, [
- hasAllRequiredFields,
- form,
- item.id,
- customization.dataset,
- hasDefaultValue,
- isDefaultValueLoading,
- ]);
-
- const handleIsRequiredChange = useCallback(
- ({ target: { checked } }: CheckboxChangeEvent) => {
- const currentFilters = form.getFieldValue('filters') || {};
- const currentItem = currentFilters[item.id] || {};
- const currentControlValues = currentItem.controlValues || {};
-
- if (checked) {
- const updatedValues = {
- controlValues: {
- ...currentControlValues,
- enableEmptyFilter: checked,
- },
- hasDefaultValue: true,
- };
- setChartCustomizationFieldValues(item.id, updatedValues);
- setHasDefaultValue(true);
- fetchDefaultValueData();
- } else {
- const updatedValues = {
- controlValues: {
- ...currentControlValues,
- enableEmptyFilter: checked,
- },
- };
- setChartCustomizationFieldValues(item.id, updatedValues);
- }
-
- formChanged();
- },
- [
- form,
- item.id,
- setChartCustomizationFieldValues,
- formChanged,
- fetchDefaultValueData,
- ],
- );
-
- return (
- <div>
- <StyledContainer>
- <StyledFormItem
- name={['filters', item.id, 'name']}
- label={t('Dynamic group by name')}
- initialValue={customization.name || ''}
- rules={[{ required: !isRemoved, message: t('Please enter a name') }]}
- >
- <Input
- placeholder={t('Name your dynamic group by')}
- onChange={debouncedFormChanged}
- />
- </StyledFormItem>
-
- <StyledFormItem
- name={['filters', item.id, 'dataset']}
- label={
- <>
- {t('Dataset')}
- <InfoTooltip
- tooltip={t('Select the dataset this group by will use')}
- placement="right"
- />
- </>
- }
- initialValue={datasetValue}
- rules={[
- { required: !isRemoved, message: t('Please select a dataset') },
- ]}
- >
- <DatasetSelect
- excludeDatasetIds={excludeDatasetIds}
- value={
- mostUsedDataset(loadedDatasets, charts)
- ? {
- value: mostUsedDataset(loadedDatasets, charts),
- label: `Dataset ${mostUsedDataset(loadedDatasets, charts)}`,
- }
- : undefined
- }
- onChange={(dataset: {
- label: string | ReactNode;
- value: number;
- }) => {
- const datasetId = dataset.value;
-
- const fetchDatasetAndUpdate = async () => {
- try {
- const cachedData = getCachedDataset(datasetId);
- let data;
-
- if (cachedData) {
- data = { result: cachedData };
- } else {
- const response = await fetch(
- `/api/v1/dataset/${datasetId}`,
- );
- data = await response.json();
-
- if (data?.result) {
- setCachedDataset(datasetId, {
- ...data.result,
- metrics: data.result.metrics || [],
- columns: data.result.columns || [],
- });
- }
- }
-
- if (data?.result) {
- const datasetWithInfo = {
- value: datasetId,
- label: DatasetSelectLabel({
- id: datasetId,
- table_name: data.result.table_name || '',
- schema: data.result.schema || '',
- database: {
- database_name:
- (data.result.database?.database_name as string) ||
- '',
- },
- }),
- table_name: data.result.table_name,
- schema: data.result.schema,
- };
-
- setFormFieldValues({
- dataset: datasetWithInfo,
- datasetInfo: datasetWithInfo,
- column: null,
- defaultValueQueriesData: null,
- defaultValue: undefined,
- defaultDataMask: undefined,
- });
-
- fetchDatasetInfo();
- formChanged();
- }
- } catch (error) {
- console.error('Error fetching dataset info:', error);
-
- const datasetWithInfo = {
- value: datasetId,
- label: `Dataset ${datasetId}`,
- table_name: `Dataset ${datasetId}`,
- };
-
- setFormFieldValues({
- dataset: datasetWithInfo,
- datasetInfo: datasetWithInfo,
- column: null,
- defaultValueQueriesData: null,
- defaultValue: undefined,
- defaultDataMask: undefined,
- });
-
- form.setFields([
- {
- name: ['filters', item.id, 'dataset'],
- value: datasetWithInfo,
- },
- {
- name: ['filters', item.id, 'datasetInfo'],
- value: datasetWithInfo,
- },
- ]);
-
- fetchDatasetInfo();
- formChanged();
- }
- };
-
- fetchDatasetAndUpdate();
- }}
- />
- </StyledFormItem>
- </StyledContainer>
-
- <Collapse
- defaultActiveKey={['settings']}
- expandIconPosition="right"
- modalMode
- >
- <Collapse.Panel header={t('Customization settings')} key="settings">
- <StyledFormItem
- name={['filters', item.id, 'description']}
- label={t('Description')}
- initialValue={customization.description || ''}
- css={css`
- width: 100% !important;
- `}
- >
- <TextArea
- placeholder={t(
- 'Add description that will be displayed when hovering over the label...',
- )}
- onChange={debouncedFormChanged}
- autoSize={{ minRows: 2, maxRows: 3 }}
- css={css`
- resize: none;
- `}
- />
- </StyledFormItem>
-
- <StyledFormItem name={['filters', item.id, 'sortFilter']}>
- <CollapsibleControl
- checked={
- form.getFieldValue('filters')?.[item.id]?.sortFilter ??
- customization.sortFilter ??
- false
- }
- initialValue={customization.sortFilter ?? false}
- title={t('Sort filter values')}
- onChange={checked => {
- setFormFieldValues({
- sortFilter: checked,
- ...(checked === false
- ? {
- sortAscending: undefined,
- sortMetric: undefined,
- }
- : {}),
- });
- formChanged();
- }}
- >
- <StyledFormItem
- name={['filters', item.id, 'sortAscending']}
- label={<CheckboxLabel>{t('Sort type')}</CheckboxLabel>}
- initialValue={customization.sortAscending !== false}
- >
- <StyledRadioGroup
- options={[
- { label: t('Sort ascending'), value: true },
- { label: t('Sort descending'), value: false },
- ]}
- onChange={value => {
- setFormFieldValues({
- sortAscending: value.target.value,
- });
- formChanged();
- }}
- />
- </StyledFormItem>
-
- {customization.sortFilter && metrics.length > 0 && (
- <StyledFormItem
- name={['filters', item.id, 'sortMetric']}
- label={
- <>
- <CheckboxLabel>{t('Sort Metric')}</CheckboxLabel>
- <InfoTooltip
- placement="top"
- tooltip={t(
- 'If a metric is specified, sorting will be done based on the metric value',
- )}
- />
- </>
- }
- initialValue={customization.sortMetric}
- >
- <Select
- allowClear
- ariaLabel={t('Sort metric')}
- value={
- form.getFieldValue('filters')?.[item.id]?.sortMetric ??
- customization.sortMetric
- }
- options={metrics.map((metric: Metric) => ({
- value: metric.metric_name,
- label: metric.verbose_name ?? metric.metric_name,
- }))}
- onChange={(value: SelectValue) => {
- const stringValue =
- value !== null && value !== undefined
- ? String(value)
- : undefined;
-
- setFormFieldValues({
- sortMetric: stringValue,
- });
- formChanged();
- }}
- />
- </StyledFormItem>
- )}
- </CollapsibleControl>
- </StyledFormItem>
-
- <StyledFormItem
- name={['filters', item.id, 'defaultValueQueriesData']}
- hidden
- initialValue={customization.defaultValueQueriesData || null}
- >
- <Input />
- </StyledFormItem>
-
- <StyledFormItem name={['filters', item.id, 'hasDefaultValue']}>
- <CollapsibleControl
- checked={hasDefaultValue}
- initialValue={customization.hasDefaultValue ?? false}
- disabled={
- !!form.getFieldValue([
- 'filters',
- item.id,
- 'controlValues',
- 'enableEmptyFilter',
- ]) || selectFirst
- }
- title={t('Dynamic group by has a default value')}
- tooltip={getDefaultValueTooltip()}
- onChange={checked => {
- setHasDefaultValue(checked);
- if (checked) {
- setSelectFirst(false);
- }
- setFormFieldValues({
- hasDefaultValue: checked,
- ...(checked ? { selectFirst: false } : {}),
- ...(checked === false
- ? {
- defaultDataMask: null,
- defaultValue: undefined,
- defaultValueQueriesData: null,
- column: null,
- }
- : {}),
- });
-
- if (checked) {
- fetchDefaultValueData();
- setTimeout(() => {
- form.validateFields([
- ['filters', item.id, 'defaultDataMask'],
- ]);
- }, 0);
- } else {
- // Clear validation errors when unchecking
- form.setFields([
- {
- name: ['filters', item.id, 'defaultDataMask'],
- errors: [],
- },
- ]);
- }
- formChanged();
- }}
- >
- <StyledFormItem
- name={['filters', item.id, 'defaultDataMask']}
- initialValue={customization.defaultDataMask || null}
- label={<CheckboxLabel>{t('Default Value')}</CheckboxLabel>}
- required={hasDefaultValue}
- rules={[
- {
- validator: async () => {
- const current =
- form.getFieldValue(['filters', item.id]) || {};
- if (!current.hasDefaultValue && !current.isRequired) {
- return Promise.resolve();
- }
-
- const allFieldsFilled = hasAllRequiredFields();
- if (!allFieldsFilled) {
- return Promise.resolve();
- }
-
- const { column, defaultDataMask } = current;
- const hasValidColumn =
- column &&
- (typeof column === 'string'
- ? column.trim() !== ''
- : Array.isArray(column)
- ? column.length > 0
- : false);
-
- if (!hasValidColumn) {
- return Promise.reject(
- new Error(t('Please select a column')),
- );
- }
-
- if (current.isRequired) {
- const hasDefaultValue =
- defaultDataMask?.filterState?.value &&
- (Array.isArray(defaultDataMask.filterState.value)
- ? defaultDataMask.filterState.value.length > 0
- : defaultDataMask.filterState.value !== null &&
- defaultDataMask.filterState.value !== undefined);
-
- if (!hasDefaultValue) {
- return Promise.reject(
- new Error(
- t(
- 'Default value is required when "Dynamic group by value is required" is enabled',
- ),
- ),
- );
- }
- }
-
- return Promise.resolve();
- },
- },
- ]}
- >
- {(() => {
- const showDefaultValue = shouldShowDefaultValue();
-
- if (error || showDefaultValue) {
- return (
- <StyledMarginTop>
- {error ? (
- <div style={{ color: theme.colorErrorText }}>
- {t('Cannot load filter: ')}{' '}
- {error.message || error.error || 'Unknown error'}
- </div>
- ) : (
- <div>
- <StyledFormItem
- name={['filters', item.id, 'column']}
- label={t('Select Column')}
- initialValue={customization.column || null}
- >
- <Select
- allowClear
- placeholder={t('Select column')}
- value={customization.column || null}
- onChange={(value: string) => {
- if (value) {
- setFormFieldValues({
- column: value,
- defaultDataMask: {
- extraFormData: {},
- filterState: {
- value: [value],
- },
- ownState: {
- column: value,
- },
- },
- chartConfiguration: {
- tooltip: {
- appendToBody: true,
- confine: true,
- },
- },
- });
-
- form.validateFields([
- ['filters', item.id, 'column'],
- ['filters', item.id, 'defaultDataMask'],
- ]);
- formChanged();
- } else {
- setFormFieldValues({
- column: null,
- defaultDataMask: null,
- });
-
- form.validateFields([
- ['filters', item.id, 'column'],
- ['filters', item.id, 'defaultDataMask'],
- ]);
- formChanged();
- }
- }}
- options={(
- form.getFieldValue('filters')?.[item.id]
- ?.defaultValueQueriesData || []
- ).map((option: ColumnOption) => ({
- ...option,
- label: option.label,
- }))}
- notFoundContent={t(
- 'No compatible columns found',
- )}
- />
- </StyledFormItem>
- </div>
- )}
- </StyledMarginTop>
- );
- }
-
- return (
- <StyledMarginTop>
- {isDefaultValueLoading ? (
- <Loading position="inline-centered" />
- ) : (
- t('Fill all required fields to enable "Default Value"')
- )}
- </StyledMarginTop>
- );
- })()}
- </StyledFormItem>
- </CollapsibleControl>
- </StyledFormItem>
-
- <StyledFormItem
- name={['filters', item.id, 'controlValues', 'enableEmptyFilter']}
- rules={[
- {
- validator: isRequiredValidator,
- },
- ]}
- >
- <Checkbox
- checked={
- !!form.getFieldValue([
- 'filters',
- item.id,
- 'controlValues',
- 'enableEmptyFilter',
- ])
- }
- onChange={handleIsRequiredChange}
- >
- {t('Dynamic group by value is required')}
- <InfoTooltip
- tooltip={t(
- 'User must select a value before applying the filter',
- )}
- placement="right"
- />
- </Checkbox>
- </StyledFormItem>
-
- <StyledFormItem name={['filters', item.id, 'selectFirst']}>
- <CollapsibleControl
- checked={selectFirst}
- initialValue={customization.selectFirst ?? false}
- title={t('Select first filter value by default')}
- tooltip={t(
- "When using this option, default value can't be set. Using this option may impact the load times for your dashboard.",
- )}
- onChange={async checked => {
- setSelectFirst(checked);
-
- if (checked) {
- setHasDefaultValue(false);
- const formValues =
- form.getFieldValue('filters')?.[item.id] || {};
- const datasetColumns =
- formValues.defaultValueQueriesData || [];
-
- if (datasetColumns.length > 0) {
- const firstColumn = datasetColumns[0];
- setFormFieldValues({
- selectFirst: checked,
- hasDefaultValue: false,
- defaultDataMask: null,
- defaultValue: undefined,
- defaultValueQueriesData: null,
- column: firstColumn.value,
- });
- } else {
- await fetchDefaultValueData();
- setFormFieldValues({
- selectFirst: checked,
- hasDefaultValue: false,
- defaultDataMask: null,
- defaultValue: undefined,
- defaultValueQueriesData: null,
- });
- }
- } else {
- const isRequiredChecked = !!form.getFieldValue([
- 'filters',
- item.id,
- 'controlValues',
- 'enableEmptyFilter',
- ]);
-
- if (isRequiredChecked) {
- setHasDefaultValue(true);
- setFormFieldValues({
- selectFirst: checked,
- hasDefaultValue: true,
- column: null,
- });
- } else {
- setFormFieldValues({
- selectFirst: checked,
- column: null,
- });
- }
- }
- formChanged();
- }}
- >
- <div />
- </CollapsibleControl>
- </StyledFormItem>
-
- <StyledFormItem name={['filters', item.id, 'canSelectMultiple']}>
- <CollapsibleControl
- checked={canSelectMultiple}
- initialValue={customization.canSelectMultiple ?? true}
- title={t('Can select multiple values')}
- tooltip={t(
- 'Allow users to select multiple values for this filter',
- )}
- onChange={checked => {
- setCanSelectMultiple(checked);
- setFormFieldValues({
- canSelectMultiple: checked,
- });
- formChanged();
- }}
- >
- <div />
- </CollapsibleControl>
- </StyledFormItem>
- </Collapse.Panel>
- </Collapse>
- </div>
- );
-};
-
-export default ChartCustomizationForm;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationModal.tsx
deleted file mode 100644
index 415cc023f1..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationModal.tsx
+++ /dev/null
@@ -1,700 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useState, useEffect, useMemo, useCallback, memo } from 'react';
-import { t } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/ui';
-import { useSelector } from 'react-redux';
-import { isEmpty, isEqual, sortBy, debounce } from 'lodash';
-import { Form } from '@superset-ui/core/components';
-import type {
- DatasourcesState,
- ChartsState,
- RootState,
-} from 'src/dashboard/types';
-import { DatasetSelectLabel } from 'src/features/datasets/DatasetSelectLabel';
-import { mostUsedDataset } from '../FiltersConfigModal/FiltersConfigForm/utils';
-import ChartCustomizationTitlePane from './ChartCustomizationTitlePane';
-import ChartCustomizationForm from './ChartCustomizationForm';
-import { createDefaultChartCustomizationItem } from './utils';
-import { ChartCustomizationItem } from './types';
-import RemovedFilter from '../FiltersConfigModal/FiltersConfigForm/RemovedFilter';
-import { selectChartCustomizationItems } from './selectors';
-import { BaseConfigModal } from '../ConfigModal/BaseConfigModal';
-
-export interface ChartCustomizationModalProps {
- isOpen: boolean;
- dashboardId: number;
- chartId?: number;
- initialItemId?: string;
- onCancel: () => void;
- onSave: (dashboardId: number, items: ChartCustomizationItem[]) => void;
-}
-
-const ChartCustomizationModal = ({
- isOpen,
- dashboardId,
- chartId,
- initialItemId,
- onCancel,
- onSave,
-}: ChartCustomizationModalProps) => {
- const theme = useTheme();
- const [expanded, setExpanded] = useState(false);
- const [form] = Form.useForm();
- const [items, setItems] = useState<ChartCustomizationItem[]>([]);
- const [currentId, setCurrentId] = useState<string | null>(null);
- const [saveAlertVisible, setSaveAlertVisible] = useState(false);
- const [initialLoadComplete, setInitialLoadComplete] = useState(false);
- const [removedItems, setRemovedItems] = useState<
- Record<string, { isPending: boolean; timerId?: number } | null>
- >({});
-
- const [itemChanges, setItemChanges] = useState<{
- modified: string[];
- deleted: string[];
- reordered: string[];
- }>({
- modified: [],
- deleted: [],
- reordered: [],
- });
-
- const resetItemChanges = () => {
- setItemChanges({
- modified: [],
- deleted: [],
- reordered: [],
- });
- };
-
- const [erroredItems, setErroredItems] = useState<string[]>([]);
-
- const hasUnsavedChanges = useCallback(() => {
- const changed = form.getFieldValue('changed');
- const isFieldsTouched = form.isFieldsTouched();
- const hasNewItems = items.some(item => !item.id.startsWith('existing_'));
- const hasRemovedItems = items.some(item => item.removed);
-
- return changed || isFieldsTouched || hasNewItems || hasRemovedItems;
- }, [form, items]);
-
- const validateForm = useCallback(async () => {
- try {
- const values = await form.validateFields();
- setErroredItems([]);
- return values;
- } catch (error: unknown) {
- if (error && typeof error === 'object' && 'errorFields' in error) {
- const { errorFields } = error as {
- errorFields: Array<{ name: (string | number)[] }>;
- };
- const errorItemIds = errorFields
- .map(field => field.name[1])
- .filter(
- (id: string | number) =>
- id && items.some(item => item.id === String(id)),
- )
- .map(String);
- setErroredItems(errorItemIds);
- }
- return null;
- }
- }, [form, items]);
-
- const handleErroredItems = useCallback(() => {
- const formValidationFields = form.getFieldsError();
- const erroredItemIds: string[] = [];
-
- formValidationFields.forEach(field => {
- const itemId = field.name[1] as string;
- if (
- field.errors.length > 0 &&
- !erroredItemIds.includes(itemId) &&
- !removedItems[itemId]
- ) {
- erroredItemIds.push(itemId);
- }
- });
-
- if (!erroredItemIds.length && erroredItems.length > 0) {
- setErroredItems([]);
- return;
- }
- if (
- erroredItemIds.length > 0 &&
- !isEqual(sortBy(erroredItems), sortBy(erroredItemIds))
- ) {
- setErroredItems(erroredItemIds);
- }
- }, [form, erroredItems, removedItems]);
-
- const resetForm = useCallback(
- (isSaving = false) => {
- setItems([]);
- setCurrentId(null);
- setSaveAlertVisible(false);
- setInitialLoadComplete(false);
- setErroredItems([]);
-
- if (!isSaving) {
- form.resetFields();
- form.setFieldsValue({ changed: false });
- }
- },
- [form],
- );
-
- const handleSave = useCallback(async () => {
- const values = await validateForm();
-
- if (values) {
- const updatedItems = items.map(item => {
- const formItemValues = values.filters?.[item.id] || {};
- const isDeleted = itemChanges.deleted.includes(item.id);
-
- const rawDataset = formItemValues.dataset;
-
- const datasetId =
- typeof rawDataset === 'object' && rawDataset?.value
- ? rawDataset.value
- : rawDataset;
-
- const formDatasetInfo = formItemValues.datasetInfo;
- const datasetInfo =
- formDatasetInfo &&
- typeof formDatasetInfo === 'object' &&
- formDatasetInfo.table_name
- ? {
- value: formDatasetInfo.value,
- label: formDatasetInfo.label,
- table_name: formDatasetInfo.table_name,
- schema: formDatasetInfo.schema,
- }
- : item.customization.datasetInfo;
-
- const updatedItem = {
- ...item,
- removed: isDeleted,
- customization: {
- ...item.customization,
- ...formItemValues,
- dataset: datasetId,
- datasetInfo,
- },
- };
-
- return updatedItem;
- });
-
- onSave(dashboardId, updatedItems);
- resetForm(true);
- resetItemChanges();
- onCancel();
- } else if (erroredItems.length > 0) {
- setCurrentId(erroredItems[0]);
- }
- }, [
- validateForm,
- items,
- itemChanges,
- onSave,
- dashboardId,
- resetForm,
- resetItemChanges,
- onCancel,
- erroredItems,
- ]);
-
- const handleConfirmCancel = useCallback(() => {
- resetForm();
- onCancel();
- }, [resetForm, onCancel]);
-
- const handleCancel = useCallback(() => {
- if (hasUnsavedChanges()) {
- setSaveAlertVisible(true);
- } else {
- handleConfirmCancel();
- }
- }, [hasUnsavedChanges, handleConfirmCancel]);
-
- const existingItems = useSelector(selectChartCustomizationItems);
-
- const loadedDatasets = useSelector<RootState, DatasourcesState>(
- ({ datasources }) => datasources,
- );
- const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
-
- const addItem = useCallback(() => {
- const usedDatasetIds = new Set<number>();
- items.forEach(existingItem => {
- if (existingItem.removed) return;
-
- const { dataset } = existingItem.customization;
- if (dataset) {
- let datasetId: number;
- if (
- typeof dataset === 'object' &&
- dataset !== null &&
- 'value' in dataset
- ) {
- datasetId = Number((dataset as { value: string | number }).value);
- } else {
- datasetId = Number(dataset);
- }
-
- if (!Number.isNaN(datasetId)) {
- usedDatasetIds.add(datasetId);
- }
- }
- });
- let fallbackDatasetId: number | undefined = mostUsedDataset(
- loadedDatasets,
- charts,
- );
-
- if (fallbackDatasetId && usedDatasetIds.has(Number(fallbackDatasetId))) {
- const availableDatasets = Object.values(loadedDatasets).filter(
- dataset => !usedDatasetIds.has(dataset.id),
- );
-
- fallbackDatasetId =
- availableDatasets.length > 0 ? availableDatasets[0].id : undefined;
- }
-
- const item = createDefaultChartCustomizationItem(
- chartId,
- fallbackDatasetId,
- );
- setItems([...items, item]);
- setCurrentId(item.id);
-
- const currentFormValues = form.getFieldsValue();
- let formattedDataset = null;
-
- if (fallbackDatasetId) {
- const datasetInfo = Object.values(loadedDatasets).find(
- dataset => dataset.id === Number(fallbackDatasetId),
- );
-
- formattedDataset = datasetInfo
- ? {
- value: fallbackDatasetId,
- label: DatasetSelectLabel({
- id: Number(fallbackDatasetId),
- table_name: datasetInfo.table_name || '',
- schema: datasetInfo.schema || '',
- database: {
- database_name:
- (datasetInfo.database?.database_name as string) ||
- (datasetInfo.database?.name as string) ||
- '',
- },
- }),
- table_name: datasetInfo.table_name,
- schema: datasetInfo.schema,
- }
- : {
- value: fallbackDatasetId,
- label: `Dataset ${fallbackDatasetId}`,
- };
- }
-
- form.setFieldsValue({
- filters: {
- ...currentFormValues.filters,
- [item.id]: {
- name: '',
- description: '',
- dataset: formattedDataset,
- column: null,
- sortFilter: false,
- sortAscending: true,
- sortMetric: null,
- hasDefaultValue: false,
- isRequired: false,
- selectFirst: false,
- },
- },
- });
- }, [items, chartId, loadedDatasets, charts, form]);
-
- const restoreItem = useCallback(
- (id: string) => {
- const removal = removedItems[id];
- if (removal?.isPending && removal.timerId) {
- clearTimeout(removal.timerId);
- }
-
- setRemovedItems(current => ({ ...current, [id]: null }));
-
- setItemChanges(prev => ({
- ...prev,
- deleted: prev.deleted.filter(deletedId => deletedId !== id),
- }));
- },
- [removedItems],
- );
-
- const handleRemoveItem = useCallback(
- (id: string) => {
- const completeItemRemoval = (itemId: string) => {
- setRemovedItems(removedItems => ({
- ...removedItems,
- [itemId]: { isPending: false },
- }));
- };
-
- const timerId = window.setTimeout(() => {
- completeItemRemoval(id);
- }, 3000);
-
- setRemovedItems(removedItems => ({
- ...removedItems,
- [id]: { isPending: true, timerId },
- }));
-
- setItemChanges(prev => {
- const newChanges = {
- ...prev,
- deleted: [...prev.deleted, id],
- };
- return newChanges;
- });
-
- setTimeout(() => {
- handleErroredItems();
- }, 0);
-
- if (currentId === id) {
- const remainingItems = items.filter(item => item.id !== id);
- if (remainingItems.length > 0) {
- setCurrentId(remainingItems[0].id);
- }
- }
- },
- [items, currentId, handleErroredItems],
- );
-
- useEffect(() => {
- const currentItemRemoved = removedItems[currentId || ''];
- if (currentItemRemoved && !currentItemRemoved.isPending) {
- const nextItem = items.find(
- item => !removedItems[item.id] && item.id !== currentId,
- );
- setCurrentId(nextItem?.id || null);
- }
- }, [currentId, removedItems, items]);
-
- const handleValuesChange = useMemo(
- () =>
- debounce(() => {
- setSaveAlertVisible(false);
- handleErroredItems();
- }, 1000),
- [handleErroredItems],
- );
-
- const handleToggleExpand = useCallback(() => {
- setExpanded(!expanded);
- }, [expanded]);
-
- useEffect(() => {
- if (isOpen && !initialLoadComplete) {
- form.resetFields();
- resetItemChanges();
-
- if (existingItems && existingItems.length > 0) {
- setItems(existingItems);
-
- const initialItem = initialItemId
- ? existingItems.find(item => item.id === initialItemId)
- : existingItems[0];
-
- const selectedItemId = initialItem?.id || existingItems[0].id;
- setCurrentId(selectedItemId);
-
- const formFilters: Record<string, any> = {};
- existingItems.forEach(item => {
- const datasetId = item.customization.dataset;
- const savedDatasetInfo = item.customization.datasetInfo as
- | {
- value: number;
- label: string;
- table_name: string;
- schema?: string;
- database?: {
- database_name?: string;
- name?: string;
- };
- }
- | undefined;
- const datasetInfo = datasetId
- ? Object.values(loadedDatasets).find(
- dataset => dataset.id === Number(datasetId),
- )
- : null;
-
- formFilters[item.id] = {
- ...item.customization,
- dataset: datasetId
- ? typeof datasetId === 'object' && !Array.isArray(datasetId)
- ? datasetId
- : savedDatasetInfo && savedDatasetInfo.table_name
- ? {
- value: datasetId,
- label: DatasetSelectLabel({
- id: Number(datasetId),
- table_name: savedDatasetInfo.table_name || '',
- schema: savedDatasetInfo.schema || '',
- database: {
- database_name:
- savedDatasetInfo.database?.database_name ||
- savedDatasetInfo.database?.name ||
- '',
- },
- }),
- table_name: savedDatasetInfo.table_name,
- schema: savedDatasetInfo.schema,
- }
- : datasetInfo
- ? {
- value: datasetId,
- label: DatasetSelectLabel({
- id: Number(datasetId),
- table_name: datasetInfo.table_name || '',
- schema: datasetInfo.schema || '',
- database: {
- database_name:
- (datasetInfo.database?.database_name as string) ||
- (datasetInfo.database?.name as string) ||
- '',
- },
- }),
- table_name: datasetInfo.table_name,
- schema: datasetInfo.schema,
- }
- : {
- value: datasetId,
- label: `Dataset ${datasetId}`,
- }
- : null,
- };
- });
-
- const initialFormValues = {
- filters: formFilters,
- changed: false,
- };
-
- form.setFieldsValue(initialFormValues);
- } else {
- const fallbackDatasetId = mostUsedDataset(loadedDatasets, charts);
- const newItem = createDefaultChartCustomizationItem(
- chartId,
- fallbackDatasetId,
- );
- setCurrentId(newItem.id);
- setItems([newItem]);
-
- const datasetInfo = fallbackDatasetId
- ? Object.values(loadedDatasets).find(
- dataset => dataset.id === Number(fallbackDatasetId),
- )
- : null;
-
- const initialFormValues = {
- filters: {
- [newItem.id]: {
- name: '',
- description: '',
- dataset: datasetInfo
- ? {
- value: fallbackDatasetId,
- label: DatasetSelectLabel({
- id: Number(fallbackDatasetId),
- table_name: datasetInfo.table_name || '',
- schema: datasetInfo.schema || '',
- database: {
- database_name:
- (datasetInfo.database?.database_name as string) ||
- (datasetInfo.database?.name as string) ||
- '',
- },
- }),
- table_name: datasetInfo.table_name,
- schema: datasetInfo.schema,
- }
- : fallbackDatasetId
- ? String(fallbackDatasetId)
- : null,
- column: null,
- sortFilter: false,
- sortAscending: true,
- sortMetric: null,
- hasDefaultValue: false,
- isRequired: false,
- selectFirst: false,
- },
- },
- changed: false,
- };
-
- form.setFieldsValue(initialFormValues);
- }
- setInitialLoadComplete(true);
- }
- }, [
- isOpen,
- initialLoadComplete,
- existingItems,
- initialItemId,
- form,
- chartId,
- loadedDatasets,
- charts,
- ]);
- useEffect(() => {
- if (!isOpen) {
- setInitialLoadComplete(false);
- }
- }, [isOpen]);
-
- useEffect(() => {
- if (!isEmpty(items)) {
- setErroredItems(prevErroredItems =>
- prevErroredItems.filter(
- f => !items.find(item => item.id === f)?.removed,
- ),
- );
- }
- }, [items]);
-
- const leftPane = (
- <div
- css={{
- minWidth: '290px',
- maxWidth: '290px',
- height: '100%',
- display: 'flex',
- flexDirection: 'column',
- overflow: 'hidden',
- borderRight: `1px solid ${theme.colorSplit}`,
- }}
- >
- <ChartCustomizationTitlePane
- items={items.filter(
- item => !removedItems[item.id] || removedItems[item.id]?.isPending,
- )}
- currentId={currentId}
- chartId={chartId}
- setCurrentId={setCurrentId}
- onChange={setCurrentId}
- onAdd={addItem}
- onRemove={handleRemoveItem}
- restoreItem={restoreItem}
- removedItems={removedItems}
- erroredItems={erroredItems}
- />
- </div>
- );
-
- const rightPane = (
- <div
- css={{
- flex: 1,
- overflow: 'auto',
- padding: `${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px 0`,
- }}
- >
- {items
- .filter(
- item => !removedItems[item.id] || removedItems[item.id]?.isPending,
- )
- .map(item => {
- const isRemoved = !!removedItems[item.id];
- return (
- <div
- key={item.id}
- css={{
- display: item.id === currentId ? 'block' : 'none',
- }}
- >
- {isRemoved ? (
- <RemovedFilter onClick={() => restoreItem(item.id)} />
- ) : (
- <ChartCustomizationForm
- form={form}
- item={item}
- removedItems={removedItems}
- allItems={items}
- onUpdate={updatedItem => {
- setItems(prev =>
- prev.map(i =>
- i.id === updatedItem.id ? updatedItem : i,
- ),
- );
-
- form.setFieldsValue({
- changed: true,
- });
-
- handleErroredItems();
- }}
- />
- )}
- </div>
- );
- })}
- </div>
- );
-
- const content = (
- <div
- css={{
- display: 'flex',
- height: '100%',
- }}
- >
- {leftPane}
- {rightPane}
- </div>
- );
-
- return (
- <BaseConfigModal
- isOpen={isOpen}
- title={t('Chart customization')}
- expanded={expanded}
- onCancel={handleCancel}
- onSave={handleSave}
- leftPane={content}
- rightPane={null}
- form={form}
- onValuesChange={handleValuesChange}
- onToggleExpand={handleToggleExpand}
- canSave={!erroredItems.length}
- saveAlertVisible={saveAlertVisible}
- onDismissSaveAlert={() => setSaveAlertVisible(false)}
- onConfirmCancel={handleConfirmCancel}
- testId="chart-customization-modal"
- />
- );
-};
-export default memo(ChartCustomizationModal);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitleContainer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitleContainer.tsx
deleted file mode 100644
index 70cec6e068..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitleContainer.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { FC, forwardRef, MouseEvent } from 'react';
-import { t } from '@apache-superset/core';
-import { styled, css, useTheme } from '@apache-superset/core/ui';
-import { Icons, Flex } from '@superset-ui/core/components';
-import { ChartCustomizationItem } from './types';
-
-interface Props {
- items: ChartCustomizationItem[];
- currentId: string | null;
- onChange: (id: string) => void;
- onRemove: (id: string) => void;
- restoreItem: (id: string) => void;
- removedItems: Record<string, { isPending: boolean; timerId?: number } | null>;
- erroredItems?: string[];
-}
-
-const FilterTitle = styled.div<{ selected: boolean; errored: boolean }>`
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: ${({ theme }) => theme.sizeUnit * 2}px
- ${({ theme }) => theme.sizeUnit * 3}px;
- background-color: ${({ theme, selected }) =>
- selected ? theme.colorPrimaryBg : theme.colorBgTextHover};
- border: 1px solid
- ${({ theme, selected }) => (selected ? theme.colorPrimary : 'transparent')};
- border-radius: ${({ theme }) => theme.borderRadius}px;
- cursor: pointer;
-
- &:hover {
- background-color: ${({ theme }) => theme.colorBgTextHover};
- }
-
- ${({ theme, errored }) =>
- errored &&
- `
- &.errored div, &.errored .warning {
- color: ${theme.colorErrorText};
- }
- `}
-`;
-
-const LabelWrapper = styled.div`
- display: flex;
- align-items: center;
- flex: 1;
- overflow: hidden;
-`;
-
-const TitleText = styled.div`
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-`;
-
-const UndoButton = styled.span`
- margin-left: auto;
- color: ${({ theme }) => theme.colorPrimary};
- cursor: pointer;
- font-size: ${({ theme }) => theme.fontSizeSM}px;
-
- &:hover {
- text-decoration: underline;
- }
-`;
-
-const TrashIcon = styled(Icons.DeleteOutlined)`
- cursor: pointer;
- margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
- color: ${({ theme }) => theme.colorTextSecondary};
-
- &:hover {
- color: ${({ theme }) => theme.colorErrorText};
- }
-`;
-
-const StyledWarning = styled(Icons.ExclamationCircleOutlined)`
- margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
- color: ${({ theme }) => theme.colorErrorText};
-`;
-
-const ChartCustomizationTitleContainer: FC<Props> = forwardRef(
- (
- {
- items,
- currentId,
- onChange,
- onRemove,
- restoreItem,
- removedItems,
- erroredItems = [],
- },
- ref,
- ) => {
- const theme = useTheme();
- return (
- <Flex
- css={css`
- flex-direction: column;
- gap: ${theme.sizeUnit * 2}px;
- `}
- ref={ref as any}
- >
- {items.map(item => {
- const isRemoved = !!removedItems[item.id];
- const selected = item.id === currentId;
- const isErrored = erroredItems.includes(item.id);
- const displayName =
- item.customization.name?.trim() || t('[untitled]');
-
- return (
- <FilterTitle
- key={`group-by-title-${item.id}`}
- role="tab"
- tabIndex={0}
- selected={selected}
- errored={isErrored}
- onClick={() => onChange(item.id)}
- onKeyDown={e => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onChange(item.id);
- }
- }}
- >
- <LabelWrapper>
- <TitleText>
- {isRemoved ? t('(Removed)') : displayName}
- </TitleText>
- {isRemoved && (
- <UndoButton
- role="button"
- tabIndex={0}
- onClick={(e: MouseEvent<HTMLElement>) => {
- e.stopPropagation();
- restoreItem(item.id);
- }}
- >
- {t('Undo?')}
- </UndoButton>
- )}
- </LabelWrapper>
- {!isRemoved && (
- <>
- {isErrored && (
- <StyledWarning className="warning" iconSize="s" />
- )}
- <TrashIcon
- iconSize="m"
- onClick={(e: MouseEvent<HTMLElement>) => {
- e.stopPropagation();
- onRemove(item.id);
- }}
- aria-label={t('Remove group by')}
- />
- </>
- )}
- </FilterTitle>
- );
- })}
- </Flex>
- );
- },
-);
-
-export default ChartCustomizationTitleContainer;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitlePane.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitlePane.tsx
deleted file mode 100644
index d25fc769c4..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitlePane.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { FC, useRef } from 'react';
-import { t } from '@apache-superset/core';
-import { styled, useTheme } from '@apache-superset/core/ui';
-import { Button, Icons } from '@superset-ui/core/components';
-import ChartCustomizationTitleContainer from './ChartCustomizationTitleContainer';
-import { ChartCustomizationItem } from './types';
-import { createDefaultChartCustomizationItem } from './utils';
-
-interface Props {
- items: ChartCustomizationItem[];
- currentId: string | null;
- chartId?: number;
- onChange: (id: string) => void;
- onAdd: (item: ChartCustomizationItem) => void;
- onRemove: (id: string) => void;
- restoreItem: (id: string) => void;
- removedItems: Record<string, { isPending: boolean; timerId?: number } | null>;
- setCurrentId: (id: string) => void;
- erroredItems?: string[];
-}
-
-const PaneContainer = styled.div`
- height: 100%;
- display: flex;
- flex-direction: column;
- padding: ${({ theme }) => theme.sizeUnit * 3}px;
- padding-top: 2px;
-`;
-
-const ChartCustomizationTitlePane: FC<Props> = ({
- items,
- currentId,
- chartId,
- onChange,
- onAdd,
- onRemove,
- restoreItem,
- removedItems,
- setCurrentId,
- erroredItems = [],
-}) => {
- const theme = useTheme();
- const listRef = useRef<HTMLDivElement>(null);
-
- const handleAdd = () => {
- const newItem = createDefaultChartCustomizationItem(chartId);
-
- onAdd(newItem);
- setCurrentId(newItem.id);
-
- setTimeout(() => {
- listRef.current?.scroll({
- top: listRef.current.scrollHeight,
- behavior: 'smooth',
- });
- }, 50);
- };
-
- return (
- <PaneContainer>
- <div
- ref={listRef}
- css={{
- flex: 1,
- overflowY: 'auto',
- }}
- >
- <ChartCustomizationTitleContainer
- items={items}
- currentId={currentId}
- onChange={onChange}
- onRemove={onRemove}
- restoreItem={restoreItem}
- removedItems={removedItems}
- erroredItems={erroredItems}
- />
- </div>
- <div
- css={{
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'flex-start',
- paddingTop: theme.sizeUnit * 3,
- }}
- >
- <Button
- buttonSize="default"
- buttonStyle="secondary"
- aria-label={t('Add dynamic group by')}
- icon={
- <Icons.GroupOutlined
- iconColor={theme.colorPrimaryActive}
- iconSize="m"
- />
- }
- onClick={handleAdd}
- data-test="add-groupby-button"
- >
- {t('Add dynamic group by')}
- </Button>
- </div>
- </PaneContainer>
- );
-};
-
-export default ChartCustomizationTitlePane;
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/groupBySelectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/groupBySelectors.ts
deleted file mode 100644
index 7717b3e1de..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/groupBySelectors.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { createSelector } from '@reduxjs/toolkit';
-import { RootState } from 'src/dashboard/types';
-import { GroupByState } from 'src/dashboard/reducers/groupByCustomizations';
-import { ChartCustomizationItem } from './types';
-
-export const selectGroupByState = (state: RootState): GroupByState =>
- state.groupByCustomizations?.groupByState || {};
-
-export const selectGroupByValues: (
- state: RootState,
- groupById: string,
-) => string[] = createSelector(
- [selectGroupByState, (state: RootState, groupById: string) => groupById],
- (groupByState, groupById): string[] =>
- groupByState[groupById]?.selectedValues || [],
-);
-
-export const selectGroupByLoading: (
- state: RootState,
- groupById: string,
-) => boolean = createSelector(
- [selectGroupByState, (state: RootState, groupById: string) => groupById],
- (groupByState, groupById): boolean =>
- groupByState[groupById]?.isLoading || false,
-);
-
-export const selectGroupByOptions: (
- state: RootState,
- groupById: string,
-) => Array<{ label: string; value: string }> = createSelector(
- [selectGroupByState, (state: RootState, groupById: string) => groupById],
- (groupByState, groupById): Array<{ label: string; value: string }> =>
- groupByState[groupById]?.availableOptions || [],
-);
-
-export const selectGroupByFormData: (
- state: RootState,
- chartId: number,
-) => {
- groupby?: string[];
- filters?: Array<{ col: string; op: string; val: string[] }>;
-} = createSelector(
- [
- selectGroupByState,
- (state: RootState) =>
- state.dashboardInfo.metadata?.chart_customization_config || [],
- (state: RootState, chartId: number) => chartId,
- ],
- (
- groupByState,
- chartCustomizationItems: ChartCustomizationItem[],
- chartId,
- ) => {
- const groupByFormData: {
- groupby?: string[];
- filters?: Array<{ col: string; op: string; val: string[] }>;
- } = {};
-
- const matchingCustomizations = chartCustomizationItems.filter(item => {
- if (item.removed) return false;
- return !item.chartId || item.chartId === chartId;
- });
-
- const groupByColumns: string[] = [];
- const allFilters: Array<{ col: string; op: string; val: string[] }> = [];
-
- matchingCustomizations.forEach(item => {
- const groupById = `chart_customization_${item.id}`;
- const groupByValues = groupByState[groupById]?.selectedValues || [];
-
- if (item.customization?.column) {
- let columnNames: string[] = [];
-
- if (typeof item.customization.column === 'string') {
- columnNames = [item.customization.column];
- } else if (Array.isArray(item.customization.column)) {
- columnNames = item.customization.column.filter(
- col => typeof col === 'string' && col.trim() !== '',
- );
- } else if (
- typeof item.customization.column === 'object' &&
- item.customization.column !== null
- ) {
- const columnObj = item.customization.column as any;
- const columnName =
- columnObj.column_name || columnObj.name || String(columnObj);
- if (columnName && columnName.trim() !== '') {
- columnNames = [columnName];
- }
- } else {
- return;
- }
-
- if (columnNames.length === 0) {
- return;
- }
-
- columnNames.forEach(columnName => {
- if (!groupByColumns.includes(columnName)) {
- groupByColumns.push(columnName);
- }
- });
-
- columnNames.forEach(columnName => {
- if (groupByValues.length > 0) {
- allFilters.push({
- col: columnName,
- op: 'IN',
- val: groupByValues,
- });
- }
- });
- }
- });
-
- if (groupByColumns.length > 0) {
- groupByFormData.groupby = groupByColumns;
- }
-
- if (allFilters.length > 0) {
- groupByFormData.filters = allFilters;
- }
-
- return groupByFormData;
- },
-);
-
-export const selectGroupByExtraFormData: (
- state: RootState,
- chartId: number,
- datasetId: string,
-) => {
- groupby?: string[];
- order_by_cols?: string[];
- filters?: Array<{ col: string; op: string; val: string[] }>;
-} = createSelector(
- [
- selectGroupByState,
- (state: RootState) =>
- state.dashboardInfo.metadata?.chart_customization_config || [],
- (state: RootState, chartId: number, datasetId: string) => ({
- chartId,
- datasetId,
- }),
- ],
- (
- groupByState,
- chartCustomizationItems: ChartCustomizationItem[],
- { chartId, datasetId },
- ) => {
- const extraFormData: {
- groupby?: string[];
- order_by_cols?: string[];
- filters?: Array<{ col: string; op: string; val: string[] }>;
- } = {};
-
- const matchingCustomizations = chartCustomizationItems.filter(item => {
- if (item.removed) return false;
-
- const targetDatasetId = String(item.customization?.dataset || '');
- const datasetMatches = targetDatasetId === datasetId;
- const chartMatches = !item.chartId || item.chartId === chartId;
-
- return datasetMatches && chartMatches;
- });
-
- const groupByColumns: string[] = [];
- const allFilters: Array<{ col: string; op: string; val: string[] }> = [];
- let orderByConfig: string[] | undefined;
-
- matchingCustomizations.forEach(item => {
- const { customization } = item;
- const groupById = `chart_customization_${item.id}`;
- const groupByValues = groupByState[groupById]?.selectedValues || [];
-
- if (customization?.column) {
- let columnNames: string[] = [];
-
- if (typeof customization.column === 'string') {
- columnNames = [customization.column];
- } else if (Array.isArray(customization.column)) {
- columnNames = customization.column.filter(
- col => typeof col === 'string' && col.trim() !== '',
- );
- } else if (
- typeof customization.column === 'object' &&
- customization.column !== null
- ) {
- const columnObj = customization.column as any;
- const columnName =
- columnObj.column_name || columnObj.name || String(columnObj);
- if (columnName && columnName.trim() !== '') {
- columnNames = [columnName];
- }
- } else {
- return;
- }
-
- if (columnNames.length === 0) {
- return;
- }
-
- columnNames.forEach(columnName => {
- if (!groupByColumns.includes(columnName)) {
- groupByColumns.push(columnName);
- }
- });
-
- columnNames.forEach(columnName => {
- if (groupByValues.length > 0) {
- allFilters.push({
- col: columnName,
- op: 'IN',
- val: groupByValues,
- });
- }
- });
-
- if (customization.sortFilter && customization.sortMetric) {
- orderByConfig = [
- JSON.stringify([
- customization.sortMetric,
- !customization.sortAscending,
- ]),
- ];
- }
- }
- });
-
- if (groupByColumns.length > 0) {
- extraFormData.groupby = groupByColumns;
- }
-
- if (allFilters.length > 0) {
- extraFormData.filters = allFilters;
- }
-
- if (orderByConfig) {
- extraFormData.order_by_cols = orderByConfig;
- }
-
- return extraFormData;
- },
-);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/selectors.ts
deleted file mode 100644
index 547db56cb4..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/selectors.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { createSelector } from '@reduxjs/toolkit';
-import { RootState } from 'src/dashboard/types';
-import { ChartCustomizationItem } from './types';
-
-const EMPTY_ARRAY: ChartCustomizationItem[] = [];
-
-export const selectChartCustomizationItems = createSelector(
- (state: RootState) => state.dashboardInfo.metadata,
- (metadata): ChartCustomizationItem[] => {
- if (
- metadata?.chart_customization_config &&
- metadata.chart_customization_config.length > 0
- ) {
- return metadata.chart_customization_config;
- }
-
- const legacyCustomization = metadata?.native_filter_configuration?.find(
- (item: any) =>
- item.type === 'CHART_CUSTOMIZATION' &&
- item.id === 'chart_customization_groupby',
- );
-
- if (legacyCustomization?.chart_customization) {
- return legacyCustomization.chart_customization;
- }
-
- return EMPTY_ARRAY;
- },
-);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/useChartCustomizationModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/useChartCustomizationModal.tsx
deleted file mode 100644
index c5298d3b02..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/useChartCustomizationModal.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useState, useCallback } from 'react';
-import { useSelector, useDispatch } from 'react-redux';
-import { RootState } from 'src/dashboard/types';
-import { saveChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
-import { ChartCustomizationItem } from './types';
-
-export const useChartCustomizationModal = (chartId?: number) => {
- const [isOpen, setIsOpen] = useState(false);
- const dashboardId = useSelector<RootState, number>(
- state => state.dashboardInfo.id,
- );
-
- const dispatch = useDispatch();
-
- const openChartCustomizationModal = useCallback(() => setIsOpen(true), []);
- const closeChartCustomizationModal = useCallback(() => setIsOpen(false), []);
-
- const handleSave = useCallback(
- (dashboardId: number, items: ChartCustomizationItem[]) => {
- dispatch(saveChartCustomization(items));
- setIsOpen(false);
- },
- [dispatch],
- );
-
- return {
- isOpen,
- dashboardId,
- chartId,
- openChartCustomizationModal,
- closeChartCustomizationModal,
- handleSave,
- };
-};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/utils.ts
deleted file mode 100644
index 1ac7b1eb00..0000000000
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/utils.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { t } from '@apache-superset/core';
-import { ChartCustomizationItem, GroupByCustomization } from './types';
-
-export function generateGroupById(): string {
- return `groupby_${Date.now()}`;
-}
-
-export const getChartCustomizationIds = (items: ChartCustomizationItem[]) =>
- items.map(item => item.id);
-
-export const createDefaultChartCustomizationItem = (
- chartId?: number,
- defaultDatasetId?: number,
-): ChartCustomizationItem => ({
- id: generateGroupById(),
- title: t('[untitled]'),
- dataset: null,
- description: '',
- removed: false,
- chartId,
- settings: {
- sortFilter: false,
- hasDefaultValue: false,
- isRequired: false,
- selectFirstByDefault: false,
- },
- customization: {
- name: '',
- dataset: defaultDatasetId ? String(defaultDatasetId) : null,
- column: null,
- sortAscending: true,
- hasDefaultValue: false,
- isRequired: false,
- selectFirst: false,
- },
-});
-
-export const ensureValidCustomization = (
- customization: Partial<GroupByCustomization> = {},
-): GroupByCustomization => ({
- name: customization.name || '',
- dataset: customization.dataset || null,
- column: customization.column || null,
- ...customization,
-});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx
index 84a0181fe5..51ee24051a 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx
@@ -41,7 +41,6 @@ export const BaseModalWrapper = styled(StyledModal)<BaseModalWrapperProps>`
.ant-modal-body {
padding: 0px;
- overflow: auto;
}
${({ expanded }) =>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
index 3055b3ec15..24fe7a30a1 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx
@@ -22,13 +22,14 @@ import {
DataMaskState,
DataMaskStateWithId,
isDefined,
+ ChartCustomization,
+ ChartCustomizationDivider,
} from '@superset-ui/core';
import { css, SupersetTheme, styled } from '@apache-superset/core/ui';
import { Button } from '@superset-ui/core/components';
import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants';
import tinycolor from 'tinycolor2';
import { FilterBarOrientation } from 'src/dashboard/types';
-import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
import { getFilterBarTestId } from '../utils';
interface ActionButtonsProps {
@@ -37,7 +38,7 @@ interface ActionButtonsProps {
onClearAll: () => void;
dataMaskSelected: DataMaskState;
dataMaskApplied: DataMaskStateWithId;
- chartCustomizationItems?: ChartCustomizationItem[];
+ chartCustomizationItems?: (ChartCustomization | ChartCustomizationDivider)[];
isApplyDisabled: boolean;
filterBarOrientation?: FilterBarOrientation;
}
@@ -125,9 +126,13 @@ const ActionButtons = ({
},
);
- const hasChartCustomizations = chartCustomizationItems?.some(
- item => item.customization?.column && !item.removed,
- );
+ const hasChartCustomizations = chartCustomizationItems?.some(item => {
+ if (item.removed) return false;
+ const mask = dataMaskApplied[item.id] || dataMaskSelected[item.id];
+ const hasValue = isDefined(mask?.filterState?.value);
+ const hasGroupBy = isDefined(mask?.ownState?.column);
+ return hasValue || hasGroupBy;
+ });
return hasSelectedChanges || hasAppliedChanges || hasChartCustomizations;
}, [dataMaskSelected, dataMaskApplied, chartCustomizationItems]);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CustomizationsOutOfScopeCollapsible/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CustomizationsOutOfScopeCollapsible/index.tsx
new file mode 100644
index 0000000000..b548d6ac41
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/CustomizationsOutOfScopeCollapsible/index.tsx
@@ -0,0 +1,67 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ReactNode } from 'react';
+import { t } from '@apache-superset/core';
+import {
+ ChartCustomization,
+ ChartCustomizationDivider,
+} from '@superset-ui/core';
+import { css, SupersetTheme } from '@apache-superset/core/ui';
+import { Collapse } from '@superset-ui/core/components';
+
+export interface CustomizationsOutOfScopeCollapsibleProps {
+ customizationsOutOfScope: (ChartCustomization | ChartCustomizationDivider)[];
+ renderer: (
+ customization: ChartCustomization | ChartCustomizationDivider,
+ index: number,
+ ) => ReactNode;
+ forceRender?: boolean;
+}
+
+export const CustomizationsOutOfScopeCollapsible = ({
+ customizationsOutOfScope,
+ renderer,
+ forceRender = false,
+}: CustomizationsOutOfScopeCollapsibleProps) => (
+ <Collapse
+ ghost
+ bordered
+ expandIconPosition="end"
+ collapsible={customizationsOutOfScope.length === 0 ? 'disabled' : undefined}
+ items={[
+ {
+ key: 'out-of-scope-customizations',
+ label: (
+ <span
+ css={(theme: SupersetTheme) => css`
+ font-size: ${theme.fontSizeSM}px;
+ `}
+ >
+ {t(
+ 'Customizations out of scope (%d)',
+ customizationsOutOfScope.length,
+ )}
+ </span>
+ ),
+ children: customizationsOutOfScope.map(renderer),
+ forceRender,
+ },
+ ]}
+ />
+);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
index 96e2770603..814b9132f0 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx
@@ -79,7 +79,7 @@ const addFilterFlow = async () => {
// open filter config modals
userEvent.click(screen.getByTestId(getTestId('collapsable')));
userEvent.click(screen.getByLabelText('setting'));
- userEvent.click(screen.getByText('Add or edit filters'));
+ userEvent.click(screen.getByText('Filters and customizations'));
// select filter
userEvent.click(screen.getByText('Value'));
userEvent.click(screen.getByText('Time range'));
@@ -103,25 +103,27 @@ describe('FilterBar', () => {
};
const mockApi = jest.fn(async data => {
- const json = JSON.parse(data.json_metadata);
- const filterId = json.native_filter_configuration[0].id;
+ if (!data?.modified?.length) {
+ return {
+ id: 1234,
+ result: [],
+ };
+ }
+ const filterId = data.modified[0].id;
return {
id: 1234,
- result: {
- json_metadata: `{
- "label_colors":{"Girls":"#FF69B4","Boys":"#ADD8E6","girl":"#FF69B4","boy":"#ADD8E6"},
- "native_filter_configuration":[{
- "id":"${filterId}",
- "name":"${FILTER_NAME}",
- "filterType":"filter_time",
- "targets":[{"datasetId":11,"column":{"name":"color"}}],
- "defaultDataMask":{"filterState":{"value":null}},
- "controlValues":{},
- "cascadeParentIds":[],
- "scope":{"rootPath":["ROOT_ID"],"excluded":[]}
- }],
- }`,
- },
+ result: [
+ {
+ id: filterId,
+ name: FILTER_NAME,
+ filterType: 'filter_time',
+ targets: [{ datasetId: 11, column: { name: 'color' } }],
+ defaultDataMask: { filterState: { value: null } },
+ controlValues: {},
+ cascadeParentIds: [],
+ scope: { rootPath: ['ROOT_ID'], excluded: [] },
+ },
+ ],
};
});
@@ -273,6 +275,13 @@ describe('FilterBar', () => {
};
const stateWithDivider = {
...stateWithoutNativeFilters,
+ dashboardInfo: {
+ ...stateWithoutNativeFilters.dashboardInfo,
+ metadata: {
+ ...stateWithoutNativeFilters.dashboardInfo.metadata,
+ native_filter_configuration: [divider],
+ },
+ },
nativeFilters: {
filters: {
'NATIVE_FILTER_DIVIDER-1': divider,
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
index 4d7401ab3a..16e3a9fef0 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBarSettings/index.tsx
@@ -33,8 +33,6 @@ import { Space } from '@superset-ui/core/components/Space';
import { clearDataMaskState } from 'src/dataMask/actions';
import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state';
import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal';
-import { useChartCustomizationModal } from '../../ChartCustomization/useChartCustomizationModal';
-import ChartCustomizationModal from '../../ChartCustomization/ChartCustomizationModal';
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import FilterConfigurationLink from '../FilterConfigurationLink';
@@ -82,15 +80,6 @@ const FilterBarSettings = () => {
({ dashboardInfo }) => dashboardInfo.id,
);
- const {
- isOpen: isChartCustomizationModalOpen,
- dashboardId: chartCustomizationDashboardId,
- chartId: chartCustomizationChartId,
- openChartCustomizationModal,
- closeChartCustomizationModal,
- handleSave: handleChartCustomizationSave,
- } = useChartCustomizationModal();
-
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal();
const { openFilterConfigModal, FilterConfigModalComponent } =
@@ -148,7 +137,7 @@ const FilterBarSettings = () => {
} else if (selectedKey === ADD_EDIT_FILTERS_MENU_KEY) {
openFilterConfigModal();
} else if (selectedKey === CHART_CUSTOMIZATION_MENU_KEY) {
- openChartCustomizationModal();
+ openFilterConfigModal();
}
},
[
@@ -156,7 +145,6 @@ const FilterBarSettings = () => {
toggleCrossFiltering,
toggleFilterBarOrientation,
openFilterConfigModal,
- openChartCustomizationModal,
],
);
@@ -183,7 +171,7 @@ const FilterBarSettings = () => {
key: ADD_EDIT_FILTERS_MENU_KEY,
label: (
<FilterConfigurationLink>
- {t('Add or edit filters')}
+ {t('Filters and customizations')}
</FilterConfigurationLink>
),
});
@@ -202,10 +190,6 @@ const FilterBarSettings = () => {
});
items.push({ type: 'divider' });
}
- items.push({
- key: CHART_CUSTOMIZATION_MENU_KEY,
- label: t('Chart customization'),
- });
if (canEdit) {
items.push({
key: 'placement',
@@ -299,15 +283,6 @@ const FilterBarSettings = () => {
</Dropdown>
{scopingModal}
{FilterConfigModalComponent}
- {isChartCustomizationModalOpen && (
- <ChartCustomizationModal
- isOpen={isChartCustomizationModalOpen}
- dashboardId={chartCustomizationDashboardId}
- chartId={chartCustomizationChartId}
- onCancel={closeChartCustomizationModal}
- onSave={handleChartCustomizationSave}
- />
- )}
</>
);
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx
index 2a08f7800f..29de53f0af 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal.tsx
@@ -20,7 +20,8 @@
import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
-import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import { saveChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
+import { SaveChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
interface UseFilterConfigModalProps {
@@ -33,7 +34,7 @@ interface UseFilterConfigModalReturn {
isFilterConfigModalOpen: boolean;
openFilterConfigModal: () => void;
closeFilterConfigModal: () => void;
- handleFilterSave: (filterChanges: SaveFilterChangesType) => Promise<void>;
+ handleSave: (changes: SaveChangesType) => Promise<void>;
FilterConfigModalComponent: JSX.Element | null;
}
@@ -53,10 +54,26 @@ export const useFilterConfigModal = ({
setIsFilterConfigModalOpen(false);
}, []);
- const handleFilterSave = useCallback(
- async (filterChanges: SaveFilterChangesType) => {
- dispatch(await setFilterConfiguration(filterChanges));
- closeFilterConfigModal();
+ const handleSave = useCallback(
+ async (changes: SaveChangesType) => {
+ try {
+ if (changes.filterChanges) {
+ dispatch(setFilterConfiguration(changes.filterChanges));
+ }
+ if (changes.customizationChanges) {
+ dispatch(
+ saveChartCustomization(
+ changes.customizationChanges.modified,
+ changes.customizationChanges.deleted,
+ changes.customizationChanges.reordered,
+ true,
+ ),
+ );
+ }
+ closeFilterConfigModal();
+ } catch (error) {
+ // Error toast already shown in action, prevent modal close
+ }
},
[dispatch, closeFilterConfigModal],
);
@@ -64,7 +81,7 @@ export const useFilterConfigModal = ({
const FilterConfigModalComponent = isFilterConfigModalOpen ? (
<FiltersConfigModal
isOpen={isFilterConfigModalOpen}
- onSave={handleFilterSave}
+ onSave={handleSave}
onCancel={closeFilterConfigModal}
key={`filters-for-${dashboardId}`}
createNewOnOpen={createNewOnOpen}
@@ -76,7 +93,7 @@ export const useFilterConfigModal = ({
isFilterConfigModalOpen,
openFilterConfigModal,
closeFilterConfigModal,
- handleFilterSave,
+ handleSave,
FilterConfigModalComponent,
};
};
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
index 7f6f020e3c..3d41fed342 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
@@ -22,15 +22,8 @@ import {
InPortal,
OutPortal,
} from 'react-reverse-portal';
-import { truncationCSS } from '@superset-ui/core';
-import { styled, SupersetTheme } from '@apache-superset/core/ui';
-import {
- FormItem as StyledFormItem,
- Form,
- Icons,
- Tooltip,
-} from '@superset-ui/core/components';
import { FilterBarOrientation } from 'src/dashboard/types';
+import { isChartCustomization } from '@superset-ui/core';
import { checkIsMissingRequiredValue } from '../utils';
import FilterValue from './FilterValue';
import { FilterCard } from '../../FilterCard';
@@ -38,252 +31,13 @@ import { FilterBarScrollContext } from '../Vertical';
import { FilterControlProps } from './types';
import { FilterCardPlacement } from '../../FilterCard/types';
import { useIsFilterInScope } from '../../state';
-
-const FilterStyledIcon = styled.div`
- position: absolute;
- right: 0;
-`;
-
-const VerticalFilterControlTitle = styled.h4`
- font-size: ${({ theme }) => theme.fontSizeSM}px;
- color: ${({ theme }) => theme.colorText};
- overflow-wrap: anywhere;
-`;
-
-const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
- font-weight: ${({ theme }) => theme.fontWeightNormal};
- color: ${({ theme }) => theme.colorText};
- margin: 0;
- ${truncationCSS};
-`;
-
-const HorizontalOverflowFilterControlTitle = styled(
- HorizontalFilterControlTitle,
-)`
- max-width: none;
- margin: ${({ theme }) => `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit}px`};
-`;
-
-const VerticalFilterControlTitleBox = styled.div`
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
-`;
-
-const HorizontalFilterControlTitleBox = styled(VerticalFilterControlTitleBox)`
- margin-bottom: unset;
-`;
-
-const HorizontalOverflowFilterControlTitleBox = styled(
- VerticalFilterControlTitleBox,
-)`
- width: 100%;
-`;
-
-const AllFilterControlContainer = styled(Form)`
- // TODO this is a hack related to having form items inside others which is not
- // normal antd-expected usage
- .ant-form-item .ant-form-item {
- margin-bottom: 0 !important;
- }
-`;
-
-const VerticalFilterControlContainer = styled(AllFilterControlContainer)`
- width: 100%;
-
- .ant-form-item {
- margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
- }
-
- && .ant-form-item-label > label {
- text-transform: none;
- width: 100%;
- padding-right: ${({ theme }) => theme.sizeUnit * 11}px;
- }
- .ant-form-item-tooltip {
- margin-bottom: ${({ theme }) => theme.sizeUnit}px;
- }
-`;
-
-const HorizontalFilterControlContainer = styled(AllFilterControlContainer)`
- && .ant-form-item-label > label {
- margin-bottom: 0;
- text-transform: none;
- }
- .ant-form-item-tooltip {
- margin-bottom: ${({ theme }) => theme.sizeUnit}px;
- }
-`;
-
-const HorizontalOverflowFilterControlContainer = styled(
- VerticalFilterControlContainer,
-)`
- && .ant-form-item-label {
- line-height: 1;
- & > label {
- padding-right: unset;
- }
- }
-`;
-
-const VerticalFormItem = styled(StyledFormItem)<{
- inverseSelection: boolean;
-}>`
- .ant-form-item-label {
- overflow: visible;
- label.ant-form-item-required:not(.ant-form-item-required-mark-optional) {
- &::after {
- display: none;
- }
- }
- }
-
- .select-container {
- ${({ inverseSelection }) =>
- inverseSelection &&
- `
- width: 140px;
- `}
- }
-
- .select-bulk-actions {
- ${({ inverseSelection }) =>
- inverseSelection &&
- `
- flex-direction: column;
- `}
- }
-
- .ant-col {
- min-height: auto;
- }
-`;
-
-const HorizontalFormItem = styled(StyledFormItem)<{
- inverseSelection: boolean;
-}>`
- && {
- margin-bottom: 0;
- align-items: center;
- }
-
- && > .ant-row > .ant-form-item-label {
- display: flex;
- align-items: center;
- overflow: visible;
- padding-bottom: 0;
- margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
- label.ant-form-item-required:not(.ant-form-item-required-mark-optional) {
- &::after {
- display: none;
- }
- }
-
- & > label::after {
- display: none;
- }
- }
-
- .ant-form-item-control {
- min-width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px;
- }
-
- .select-container {
- ${({ inverseSelection }) =>
- inverseSelection &&
- `
- width: 164px;
- `}
- }
-
- .select-bulk-actions {
- flex-direction: column;
- }
-`;
-
-const HorizontalOverflowFormItem = VerticalFormItem;
-
-const useFilterControlDisplay = (
- orientation: FilterBarOrientation,
- overflow: boolean,
- inverseSelection: boolean,
-) =>
- useMemo(() => {
- if (orientation === FilterBarOrientation.Horizontal) {
- if (overflow) {
- return {
- FilterControlContainer: HorizontalOverflowFilterControlContainer,
- FormItem: (props: any) => (
- <HorizontalOverflowFormItem
- {...props}
- inverseSelection={inverseSelection}
- />
- ),
- FilterControlTitleBox: HorizontalOverflowFilterControlTitleBox,
- FilterControlTitle: HorizontalOverflowFilterControlTitle,
- };
- }
- return {
- FilterControlContainer: HorizontalFilterControlContainer,
- FormItem: (props: any) => (
- <HorizontalFormItem {...props} inverseSelection={inverseSelection} />
- ),
- FilterControlTitleBox: HorizontalFilterControlTitleBox,
- FilterControlTitle: HorizontalFilterControlTitle,
- };
- }
- return {
- FilterControlContainer: VerticalFilterControlContainer,
- FormItem: (props: any) => (
- <VerticalFormItem {...props} inverseSelection={inverseSelection} />
- ),
- FilterControlTitleBox: VerticalFilterControlTitleBox,
- FilterControlTitle: VerticalFilterControlTitle,
- };
- }, [orientation, overflow, inverseSelection]);
-
-const ToolTipContainer = styled.div`
- font-size: ${({ theme }) => theme.fontSize}px;
- display: flex;
-`;
-
-const RequiredFieldIndicator = () => (
- <span
- css={(theme: SupersetTheme) => ({
- color: theme.colorError,
- fontSize: `${theme.fontSizeSM}px`,
- paddingLeft: '1px',
- })}
- >
- *
- </span>
-);
-
-const DescriptionToolTip = ({ description }: { description: string }) => (
- <ToolTipContainer>
- <Tooltip
- title={description}
- placement="right"
- overlayInnerStyle={{
- display: '-webkit-box',
- WebkitLineClamp: 10,
- WebkitBoxOrient: 'vertical',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'normal',
- }}
- >
- <Icons.InfoCircleOutlined
- className="text-muted"
- role="button"
- css={(theme: SupersetTheme) => ({
- paddingLeft: `${theme.sizeUnit}px`,
- })}
- />
- </Tooltip>
- </ToolTipContainer>
-);
+import {
+ FilterStyledIcon,
+ RequiredFieldIndicator,
+ DescriptionToolTip,
+ useFilterControlDisplay,
+} from './FilterControlShared';
+import GroupByFilterCard from './GroupByFilterCard';
const FilterControl = ({
dataMaskSelected,
@@ -355,6 +109,24 @@ const FilterControl = ({
return FilterCardPlacement.Right;
}, [orientation, overflow]);
+ if (
+ isChartCustomization(filter) &&
+ filter.filterType === 'chart_customization_dynamic_groupby'
+ ) {
+ return (
+ <GroupByFilterCard
+ customizationItem={filter}
+ orientation={
+ orientation === FilterBarOrientation.Horizontal
+ ? 'horizontal'
+ : 'vertical'
+ }
+ dataMaskSelected={dataMaskSelected}
+ onFilterSelectionChange={onFilterSelectionChange}
+ />
+ );
+ }
+
return (
<>
<InPortal node={portalNode}>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControlShared.tsx
similarity index 54%
copy from superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
copy to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControlShared.tsx
index 7f6f020e3c..6b42fde4d3 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControlShared.tsx
@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { memo, useContext, useMemo, useState } from 'react';
-import {
- createHtmlPortalNode,
- InPortal,
- OutPortal,
-} from 'react-reverse-portal';
+import { useMemo } from 'react';
import { truncationCSS } from '@superset-ui/core';
import { styled, SupersetTheme } from '@apache-superset/core/ui';
import {
@@ -31,57 +26,52 @@ import {
Tooltip,
} from '@superset-ui/core/components';
import { FilterBarOrientation } from 'src/dashboard/types';
-import { checkIsMissingRequiredValue } from '../utils';
-import FilterValue from './FilterValue';
-import { FilterCard } from '../../FilterCard';
-import { FilterBarScrollContext } from '../Vertical';
-import { FilterControlProps } from './types';
-import { FilterCardPlacement } from '../../FilterCard/types';
-import { useIsFilterInScope } from '../../state';
-const FilterStyledIcon = styled.div`
+export const FilterStyledIcon = styled.div`
position: absolute;
right: 0;
`;
-const VerticalFilterControlTitle = styled.h4`
+export const VerticalFilterControlTitle = styled.h4`
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorText};
overflow-wrap: anywhere;
`;
-const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
+export const HorizontalFilterControlTitle = styled(VerticalFilterControlTitle)`
font-weight: ${({ theme }) => theme.fontWeightNormal};
color: ${({ theme }) => theme.colorText};
margin: 0;
${truncationCSS};
`;
-const HorizontalOverflowFilterControlTitle = styled(
+export const HorizontalOverflowFilterControlTitle = styled(
HorizontalFilterControlTitle,
)`
max-width: none;
margin: ${({ theme }) => `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit}px`};
`;
-const VerticalFilterControlTitleBox = styled.div`
+export const VerticalFilterControlTitleBox = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
`;
-const HorizontalFilterControlTitleBox = styled(VerticalFilterControlTitleBox)`
+export const HorizontalFilterControlTitleBox = styled(
+ VerticalFilterControlTitleBox,
+)`
margin-bottom: unset;
`;
-const HorizontalOverflowFilterControlTitleBox = styled(
+export const HorizontalOverflowFilterControlTitleBox = styled(
VerticalFilterControlTitleBox,
)`
width: 100%;
`;
-const AllFilterControlContainer = styled(Form)`
+export const AllFilterControlContainer = styled(Form)`
// TODO this is a hack related to having form items inside others which is not
// normal antd-expected usage
.ant-form-item .ant-form-item {
@@ -89,7 +79,7 @@ const AllFilterControlContainer = styled(Form)`
}
`;
-const VerticalFilterControlContainer = styled(AllFilterControlContainer)`
+export const VerticalFilterControlContainer = styled(AllFilterControlContainer)`
width: 100%;
.ant-form-item {
@@ -106,7 +96,9 @@ const VerticalFilterControlContainer = styled(AllFilterControlContainer)`
}
`;
-const HorizontalFilterControlContainer = styled(AllFilterControlContainer)`
+export const HorizontalFilterControlContainer = styled(
+ AllFilterControlContainer,
+)`
&& .ant-form-item-label > label {
margin-bottom: 0;
text-transform: none;
@@ -116,7 +108,7 @@ const HorizontalFilterControlContainer = styled(AllFilterControlContainer)`
}
`;
-const HorizontalOverflowFilterControlContainer = styled(
+export const HorizontalOverflowFilterControlContainer = styled(
VerticalFilterControlContainer,
)`
&& .ant-form-item-label {
@@ -127,8 +119,8 @@ const HorizontalOverflowFilterControlContainer = styled(
}
`;
-const VerticalFormItem = styled(StyledFormItem)<{
- inverseSelection: boolean;
+export const VerticalFormItem = styled(StyledFormItem)<{
+ inverseSelection?: boolean;
}>`
.ant-form-item-label {
overflow: visible;
@@ -160,8 +152,8 @@ const VerticalFormItem = styled(StyledFormItem)<{
}
`;
-const HorizontalFormItem = styled(StyledFormItem)<{
- inverseSelection: boolean;
+export const HorizontalFormItem = styled(StyledFormItem)<{
+ inverseSelection?: boolean;
}>`
&& {
margin-bottom: 0;
@@ -202,53 +194,14 @@ const HorizontalFormItem = styled(StyledFormItem)<{
}
`;
-const HorizontalOverflowFormItem = VerticalFormItem;
-
-const useFilterControlDisplay = (
- orientation: FilterBarOrientation,
- overflow: boolean,
- inverseSelection: boolean,
-) =>
- useMemo(() => {
- if (orientation === FilterBarOrientation.Horizontal) {
- if (overflow) {
- return {
- FilterControlContainer: HorizontalOverflowFilterControlContainer,
- FormItem: (props: any) => (
- <HorizontalOverflowFormItem
- {...props}
- inverseSelection={inverseSelection}
- />
- ),
- FilterControlTitleBox: HorizontalOverflowFilterControlTitleBox,
- FilterControlTitle: HorizontalOverflowFilterControlTitle,
- };
- }
- return {
- FilterControlContainer: HorizontalFilterControlContainer,
- FormItem: (props: any) => (
- <HorizontalFormItem {...props} inverseSelection={inverseSelection} />
- ),
- FilterControlTitleBox: HorizontalFilterControlTitleBox,
- FilterControlTitle: HorizontalFilterControlTitle,
- };
- }
- return {
- FilterControlContainer: VerticalFilterControlContainer,
- FormItem: (props: any) => (
- <VerticalFormItem {...props} inverseSelection={inverseSelection} />
- ),
- FilterControlTitleBox: VerticalFilterControlTitleBox,
- FilterControlTitle: VerticalFilterControlTitle,
- };
- }, [orientation, overflow, inverseSelection]);
+export const HorizontalOverflowFormItem = VerticalFormItem;
const ToolTipContainer = styled.div`
font-size: ${({ theme }) => theme.fontSize}px;
display: flex;
`;
-const RequiredFieldIndicator = () => (
+export const RequiredFieldIndicator = () => (
<span
css={(theme: SupersetTheme) => ({
color: theme.colorError,
@@ -260,7 +213,11 @@ const RequiredFieldIndicator = () => (
</span>
);
-const DescriptionToolTip = ({ description }: { description: string }) => (
+export const DescriptionToolTip = ({
+ description,
+}: {
+ description: string;
+}) => (
<ToolTipContainer>
<Tooltip
title={description}
@@ -285,120 +242,41 @@ const DescriptionToolTip = ({ description }: { description: string }) => (
</ToolTipContainer>
);
-const FilterControl = ({
- dataMaskSelected,
- filter,
- icon,
- onFilterSelectionChange,
- inView,
- showOverflow,
- parentRef,
- orientation = FilterBarOrientation.Vertical,
- overflow = false,
- clearAllTrigger,
- onClearAllComplete,
-}: FilterControlProps) => {
- const portalNode = useMemo(() => createHtmlPortalNode(), []);
- const [isFilterActive, setIsFilterActive] = useState(false);
-
- const { name = '<undefined>' } = filter;
-
- const isFilterInScope = useIsFilterInScope();
- const isMissingRequiredValue =
- isFilterInScope(filter) &&
- checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
- const validateStatus = isMissingRequiredValue ? 'error' : undefined;
- const isRequired = !!filter.controlValues?.enableEmptyFilter;
- const inverseSelection = !!filter.controlValues?.inverseSelection;
-
- const {
- FilterControlContainer,
- FormItem,
- FilterControlTitleBox,
- FilterControlTitle,
- } = useFilterControlDisplay(orientation, overflow, inverseSelection);
-
- const label = useMemo(
- () => (
- <FilterControlTitleBox>
- <FilterControlTitle
- id={`filter-name-${filter.id}`}
- data-test="filter-control-name"
- >
- {name}
- </FilterControlTitle>
- {isRequired && <RequiredFieldIndicator />}
- {filter.description?.trim() && (
- <DescriptionToolTip description={filter.description} />
- )}
- <FilterStyledIcon data-test="filter-icon">{icon}</FilterStyledIcon>
- </FilterControlTitleBox>
- ),
- [
- FilterControlTitleBox,
- FilterControlTitle,
- name,
- isRequired,
- filter.description,
- icon,
- ],
- );
-
- const isScrolling = useContext(FilterBarScrollContext);
- const filterCardPlacement = useMemo(() => {
+export const useFilterControlDisplay = (
+ orientation: FilterBarOrientation,
+ overflow: boolean,
+ inverseSelection?: boolean,
+) =>
+ useMemo(() => {
if (orientation === FilterBarOrientation.Horizontal) {
if (overflow) {
- return FilterCardPlacement.Left;
+ return {
+ FilterControlContainer: HorizontalOverflowFilterControlContainer,
+ FormItem: (props: any) => (
+ <HorizontalOverflowFormItem
+ {...props}
+ inverseSelection={inverseSelection}
+ />
+ ),
+ FilterControlTitleBox: HorizontalOverflowFilterControlTitleBox,
+ FilterControlTitle: HorizontalOverflowFilterControlTitle,
+ };
}
- return FilterCardPlacement.Bottom;
+ return {
+ FilterControlContainer: HorizontalFilterControlContainer,
+ FormItem: (props: any) => (
+ <HorizontalFormItem {...props} inverseSelection={inverseSelection} />
+ ),
+ FilterControlTitleBox: HorizontalFilterControlTitleBox,
+ FilterControlTitle: HorizontalFilterControlTitle,
+ };
}
- return FilterCardPlacement.Right;
- }, [orientation, overflow]);
-
- return (
- <>
- <InPortal node={portalNode}>
- <FilterValue
- dataMaskSelected={dataMaskSelected}
- filter={filter}
- showOverflow={showOverflow}
- onFilterSelectionChange={onFilterSelectionChange}
- inView={inView}
- parentRef={parentRef}
- setFilterActive={setIsFilterActive}
- orientation={orientation}
- overflow={overflow}
- validateStatus={validateStatus}
- clearAllTrigger={clearAllTrigger}
- onClearAllComplete={onClearAllComplete}
- />
- </InPortal>
- <FilterControlContainer
- layout={
- orientation === FilterBarOrientation.Horizontal && !overflow
- ? 'horizontal'
- : 'vertical'
- }
- >
- <FilterCard
- filter={filter}
- isVisible={!isFilterActive && !isScrolling}
- placement={filterCardPlacement}
- >
- <div>
- <FormItem
- label={label}
- htmlFor={filter.id}
- required={filter?.controlValues?.enableEmptyFilter}
- validateStatus={validateStatus}
- >
- <OutPortal node={portalNode} />
- </FormItem>
- </div>
- </FilterCard>
- </FilterControlContainer>
- </>
- );
-};
-
-export default memo(FilterControl);
+ return {
+ FilterControlContainer: VerticalFilterControlContainer,
+ FormItem: (props: any) => (
+ <VerticalFormItem {...props} inverseSelection={inverseSelection} />
+ ),
+ FilterControlTitleBox: VerticalFilterControlTitleBox,
+ FilterControlTitle: VerticalFilterControlTitle,
+ };
+ }, [orientation, overflow, inverseSelection]);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.test.tsx
new file mode 100644
index 0000000000..4527347e12
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import { stateWithoutNativeFilters } from 'spec/fixtures/mockStore';
+import {
+ ChartCustomizationType,
+ ChartCustomizationDivider,
+} from '@superset-ui/core';
+import { FilterBarOrientation } from 'src/dashboard/types';
+import FilterControls from './FilterControls';
+
+const mockStore = {
+ ...stateWithoutNativeFilters,
+ dashboardInfo: {
+ ...stateWithoutNativeFilters.dashboardInfo,
+ filterBarOrientation: FilterBarOrientation.Vertical,
+ },
+};
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(selector => selector(mockStore)),
+ useDispatch: () => jest.fn(),
+}));
+
+const defaultProps = {
+ dataMaskSelected: {},
+ onFilterSelectionChange: jest.fn(),
+ onPendingCustomizationDataMaskChange: jest.fn(),
+ chartCustomizationValues: [],
+};
+
+test('renders chart customization divider in vertical mode', () => {
+ const divider: ChartCustomizationDivider = {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-abc123',
+ type: ChartCustomizationType.Divider,
+ title: 'Test Divider',
+ description: 'Test description',
+ removed: false,
+ };
+
+ render(
+ <FilterControls {...defaultProps} chartCustomizationValues={[divider]} />,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'Test Divider' }),
+ ).toBeInTheDocument();
+});
+
+test('renders multiple chart customization dividers in vertical mode', () => {
+ const dividers: ChartCustomizationDivider[] = [
+ {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-abc123',
+ type: ChartCustomizationType.Divider,
+ title: 'First Divider',
+ description: 'First description',
+ removed: false,
+ },
+ {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-xyz789',
+ type: ChartCustomizationType.Divider,
+ title: 'Second Divider',
+ description: 'Second description',
+ removed: false,
+ },
+ ];
+
+ render(
+ <FilterControls {...defaultProps} chartCustomizationValues={dividers} />,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'First Divider' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: 'Second Divider' }),
+ ).toBeInTheDocument();
+});
+
+test('renders chart customization divider in horizontal mode', () => {
+ const horizontalStore = {
+ ...mockStore,
+ dashboardInfo: {
+ ...mockStore.dashboardInfo,
+ filterBarOrientation: FilterBarOrientation.Horizontal,
+ },
+ };
+
+ const useSelector = jest.requireMock('react-redux').useSelector;
+ useSelector.mockImplementation(
+ (selector: (state: typeof horizontalStore) => unknown) =>
+ selector(horizontalStore),
+ );
+
+ const divider: ChartCustomizationDivider = {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-abc123',
+ type: ChartCustomizationType.Divider,
+ title: 'Horizontal Divider',
+ description: 'Horizontal description',
+ removed: false,
+ };
+
+ render(
+ <FilterControls {...defaultProps} chartCustomizationValues={[divider]} />,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'Horizontal Divider' }),
+ ).toBeInTheDocument();
+});
+
+test('does not render removed chart customization dividers', () => {
+ const dividers: ChartCustomizationDivider[] = [
+ {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-abc123',
+ type: ChartCustomizationType.Divider,
+ title: 'Visible Divider',
+ description: 'Should be visible',
+ removed: false,
+ },
+ {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-xyz789',
+ type: ChartCustomizationType.Divider,
+ title: 'Hidden Divider',
+ description: 'Should not be visible',
+ removed: true,
+ },
+ ];
+
+ render(
+ <FilterControls {...defaultProps} chartCustomizationValues={dividers} />,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'Visible Divider' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: 'Hidden Divider' }),
+ ).not.toBeInTheDocument();
+});
+
+test('renders divider with description icon in vertical mode when description exists', () => {
+ const divider: ChartCustomizationDivider = {
+ id: 'CHART_CUSTOMIZATION_DIVIDER-abc123',
+ type: ChartCustomizationType.Divider,
+ title: 'Divider With Description',
+ description: 'This is a detailed description',
+ removed: false,
+ };
+
+ render(
+ <FilterControls {...defaultProps} chartCustomizationValues={[divider]} />,
+ );
+
+ expect(
+ screen.getByRole('heading', { name: 'Divider With Description' }),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('divider-description-icon')).toBeInTheDocument();
+});
+
+test('renders empty state when no chart customizations provided', () => {
+ const { container } = render(
+ <FilterControls {...defaultProps} chartCustomizationValues={[]} />,
+ );
+
+ expect(
+ container.querySelector('.chart-customization-item-wrapper'),
+ ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
index 407ca54096..fd9cf7d6f7 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
@@ -33,6 +33,10 @@ import {
Filter,
Divider,
isNativeFilterWithDataMask,
+ NativeFilterTarget,
+ ChartCustomization,
+ isChartCustomizationDivider,
+ ChartCustomizationDivider,
} from '@superset-ui/core';
import { css, SupersetTheme, useTheme, styled } from '@apache-superset/core/ui';
import {
@@ -40,10 +44,11 @@ import {
InPortal,
OutPortal,
} from 'react-reverse-portal';
-import { useSelector } from 'react-redux';
+import { useSelector, useDispatch } from 'react-redux';
import {
useDashboardHasTabs,
useSelectFiltersInScope,
+ useSelectCustomizationsInScope,
} from 'src/dashboard/components/nativeFilters/state';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import {
@@ -54,19 +59,36 @@ import {
import { Icons } from '@superset-ui/core/components/Icons';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
+import { setPendingChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
+import { getInitialDataMask } from 'src/dataMask/reducer';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
+import { CustomizationsOutOfScopeCollapsible } from '../CustomizationsOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent';
import crossFiltersSelector from '../CrossFilters/selectors';
import CrossFilter from '../CrossFilters/CrossFilter';
import { useFilterOutlined } from '../useFilterOutlined';
import { useChartsVerboseMaps } from '../utils';
-import GroupByFilterCard from '../../ChartCustomization/GroupByFilterCard';
-import { selectChartCustomizationItems } from '../../ChartCustomization/selectors';
+import FilterControl from './FilterControl';
+import FilterDivider from './FilterDivider';
+
+function addDataMaskToCustomization(
+ customization: ChartCustomization,
+ dataMaskSelected: DataMaskStateWithId,
+): ChartCustomization & { dataMask: DataMask } {
+ const dataMask =
+ dataMaskSelected[customization.id] ?? getInitialDataMask(customization.id);
+ return { ...customization, dataMask };
+}
type FilterControlsProps = {
dataMaskSelected: DataMaskStateWithId;
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
+ onPendingCustomizationDataMaskChange: (
+ customizationId: string,
+ dataMask: DataMask,
+ ) => void;
+ chartCustomizationValues: (ChartCustomization | ChartCustomizationDivider)[];
clearAllTriggers?: Record<string, boolean>;
onClearAllComplete?: (filterId: string) => void;
hideHeader?: boolean;
@@ -119,11 +141,14 @@ const ChartCustomizationContent = styled.div`
const FilterControls: FC<FilterControlsProps> = ({
dataMaskSelected,
onFilterSelectionChange,
+ onPendingCustomizationDataMaskChange,
+ chartCustomizationValues,
clearAllTriggers,
onClearAllComplete,
hideHeader = false,
}) => {
const theme = useTheme();
+ const dispatch = useDispatch();
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
);
@@ -140,8 +165,6 @@ const FilterControls: FC<FilterControlsProps> = ({
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
- const chartCustomizationItems = useSelector(selectChartCustomizationItems);
-
const selectedCrossFilters = useMemo(
() =>
crossFiltersSelector({
@@ -171,6 +194,14 @@ const FilterControls: FC<FilterControlsProps> = ({
const [filtersInScope, filtersOutOfScope] =
useSelectFiltersInScope(filtersWithValues);
+ const filteredChartCustomizationValues = useMemo(
+ () => chartCustomizationValues.filter(item => !item.removed),
+ [chartCustomizationValues],
+ );
+
+ const [customizationsInScope, customizationsOutOfScope] =
+ useSelectCustomizationsInScope(filteredChartCustomizationValues);
+
const hasRequiredFirst = useMemo(
() => filtersWithValues.some(filter => filter.requiredFirst),
[filtersWithValues],
@@ -178,6 +209,8 @@ const FilterControls: FC<FilterControlsProps> = ({
const dashboardHasTabs = useDashboardHasTabs();
const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0;
+ const showCustomizationCollapsePanel =
+ dashboardHasTabs && filteredChartCustomizationValues.length > 0;
const [sectionsOpen, setSectionsOpen] = useState({
filters: true,
@@ -191,6 +224,33 @@ const FilterControls: FC<FilterControlsProps> = ({
}));
}, []);
+ const handleChartCustomizationChange = useCallback(
+ (customizationItem: ChartCustomization, dataMask: DataMask) => {
+ const columnValue = dataMask.ownState?.column;
+ const existingTarget = customizationItem.targets?.[0] || {};
+
+ dispatch(
+ setPendingChartCustomization({
+ ...customizationItem,
+ targets: [
+ {
+ ...existingTarget,
+ ...(columnValue && {
+ column: {
+ ...existingTarget.column,
+ name: columnValue,
+ },
+ }),
+ },
+ ] as [Partial<NativeFilterTarget>],
+ }),
+ );
+
+ onPendingCustomizationDataMaskChange(customizationItem.id, dataMask);
+ },
+ [dispatch, onPendingCustomizationDataMaskChange],
+ );
+
const renderer = useCallback(
({ id }: Filter | Divider, index: number | undefined) => {
const filterIndex = filtersWithValues.findIndex(f => f.id === id);
@@ -207,6 +267,34 @@ const FilterControls: FC<FilterControlsProps> = ({
[filtersWithValues, portalNodes],
);
+ const customizationRenderer = useCallback(
+ (item: ChartCustomization | ChartCustomizationDivider, index: number) => {
+ if (isChartCustomizationDivider(item)) {
+ return (
+ <FilterDivider
+ key={item.id}
+ title={item.title}
+ description={item.description}
+ orientation={FilterBarOrientation.Vertical}
+ />
+ );
+ }
+ return (
+ <FilterControl
+ key={item.id}
+ filter={addDataMaskToCustomization(item, dataMaskSelected)}
+ dataMaskSelected={dataMaskSelected}
+ onFilterSelectionChange={(_, dataMask) =>
+ handleChartCustomizationChange(item, dataMask)
+ }
+ orientation={FilterBarOrientation.Vertical}
+ overflow={false}
+ />
+ );
+ },
+ [dataMaskSelected, handleChartCustomizationChange],
+ );
+
const renderVerticalContent = useCallback(
() => (
<>
@@ -254,7 +342,7 @@ const FilterControls: FC<FilterControlsProps> = ({
/>
)}
- {chartCustomizationItems.length > 0 && (
+ {customizationsInScope.length > 0 && (
<SectionContainer>
{!hideHeader && (
<SectionHeader
@@ -289,14 +377,9 @@ const FilterControls: FC<FilterControlsProps> = ({
{(hideHeader || sectionsOpen.chartCustomization) && (
<SectionContent>
<ChartCustomizationContent>
- {chartCustomizationItems
- .filter(item => !item.removed)
- .map(item => (
- <GroupByFilterCard
- key={item.id}
- customizationItem={item}
- />
- ))}
+ {customizationsInScope.map((item, index) =>
+ customizationRenderer(item, index),
+ )}
</ChartCustomizationContent>
</SectionContent>
)}
@@ -305,6 +388,15 @@ const FilterControls: FC<FilterControlsProps> = ({
)}
</SectionContainer>
)}
+
+ {showCustomizationCollapsePanel &&
+ (hideHeader || sectionsOpen.chartCustomization) && (
+ <CustomizationsOutOfScopeCollapsible
+ customizationsOutOfScope={customizationsOutOfScope}
+ renderer={customizationRenderer}
+ forceRender={false}
+ />
+ )}
</>
),
[
@@ -313,10 +405,16 @@ const FilterControls: FC<FilterControlsProps> = ({
showCollapsePanel,
filtersOutOfScope,
hasRequiredFirst,
- chartCustomizationItems,
+ customizationsInScope,
+ customizationsOutOfScope,
+ showCustomizationCollapsePanel,
+ customizationRenderer,
sectionsOpen,
toggleSection,
+ theme,
hideHeader,
+ handleChartCustomizationChange,
+ dataMaskSelected,
],
);
@@ -382,7 +480,7 @@ const FilterControls: FC<FilterControlsProps> = ({
const dividerItems = [];
if (
(crossFilters.length > 0 || nativeFiltersInScope.length > 0) &&
- chartCustomizationItems.length > 0
+ chartCustomizationValues.length > 0
) {
dividerItems.push({
id: 'chart-customization-divider',
@@ -401,9 +499,27 @@ const FilterControls: FC<FilterControlsProps> = ({
});
}
- const chartCustomizations = chartCustomizationItems
- .filter(item => !item.removed)
- .map(item => ({
+ const chartCustomizations = customizationsInScope.map(item => {
+ if (isChartCustomizationDivider(item)) {
+ return {
+ id: `chart-customization-${item.id}`,
+ element: (
+ <div
+ className="chart-customization-item-wrapper"
+ css={css`
+ flex-shrink: 0;
+ `}
+ >
+ <FilterDivider
+ title={item.title}
+ description={item.description}
+ orientation={FilterBarOrientation.Horizontal}
+ />
+ </div>
+ ),
+ };
+ }
+ return {
id: `chart-customization-${item.id}`,
element: (
<div
@@ -412,13 +528,19 @@ const FilterControls: FC<FilterControlsProps> = ({
flex-shrink: 0;
`}
>
- <GroupByFilterCard
- customizationItem={item}
- orientation="horizontal"
+ <FilterControl
+ filter={addDataMaskToCustomization(item, dataMaskSelected)}
+ dataMaskSelected={dataMaskSelected}
+ onFilterSelectionChange={(_, dataMask) =>
+ handleChartCustomizationChange(item, dataMask)
+ }
+ orientation={FilterBarOrientation.Horizontal}
+ overflow={false}
/>
</div>
),
- }));
+ };
+ });
return [
...chartCustomizations,
@@ -431,8 +553,11 @@ const FilterControls: FC<FilterControlsProps> = ({
renderer,
rendererCrossFilter,
selectedCrossFilters,
- chartCustomizationItems,
+ customizationsInScope,
theme,
+ handleChartCustomizationChange,
+ dataMaskSelected,
+ chartCustomizationValues.length,
]);
const renderHorizontalContent = useCallback(
@@ -471,17 +596,27 @@ const FilterControls: FC<FilterControlsProps> = ({
dropdownContent={
overflowedFiltersInScope.length ||
overflowedCrossFilters.length ||
- (filtersOutOfScope.length && showCollapsePanel)
+ (filtersOutOfScope.length && showCollapsePanel) ||
+ (customizationsOutOfScope.length && showCustomizationCollapsePanel)
? () => (
- <FiltersDropdownContent
- overflowedCrossFilters={overflowedCrossFilters}
- filtersInScope={overflowedFiltersInScope}
- filtersOutOfScope={filtersOutOfScope}
- renderer={renderer}
- rendererCrossFilter={rendererCrossFilter}
- showCollapsePanel={showCollapsePanel}
- forceRenderOutOfScope={hasRequiredFirst}
- />
+ <>
+ <FiltersDropdownContent
+ overflowedCrossFilters={overflowedCrossFilters}
+ filtersInScope={overflowedFiltersInScope}
+ filtersOutOfScope={filtersOutOfScope}
+ renderer={renderer}
+ rendererCrossFilter={rendererCrossFilter}
+ showCollapsePanel={showCollapsePanel}
+ forceRenderOutOfScope={hasRequiredFirst}
+ />
+ {showCustomizationCollapsePanel && (
+ <CustomizationsOutOfScopeCollapsible
+ customizationsOutOfScope={customizationsOutOfScope}
+ renderer={customizationRenderer}
+ forceRender={false}
+ />
+ )}
+ </>
)
: undefined
}
@@ -508,6 +643,9 @@ const FilterControls: FC<FilterControlsProps> = ({
overflowedCrossFilters,
filtersOutOfScope,
showCollapsePanel,
+ customizationsOutOfScope,
+ showCustomizationCollapsePanel,
+ customizationRenderer,
renderer,
rendererCrossFilter,
hasRequiredFirst,
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
index 2c1233d757..238538e7c6 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
@@ -39,6 +39,7 @@ import {
SuperChart,
ClientErrorObject,
getClientErrorObject,
+ isChartCustomization,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { useDispatch, useSelector } from 'react-redux';
@@ -52,6 +53,10 @@ import {
onFiltersRefreshSuccess,
setDirectPathToChild,
} from 'src/dashboard/actions/dashboardState';
+import {
+ setHoveredChartCustomization,
+ unsetHoveredChartCustomization,
+} from 'src/dashboard/actions/nativeFilters';
import { RESPONSIVE_WIDTH } from 'src/filters/components/common';
import { dispatchHoverAction, dispatchFocusAction } from './utils';
import { FilterControlProps } from './types';
@@ -78,7 +83,6 @@ const StyledDiv = styled.div<{
`;
const queriesDataPlaceholder = [{ data: [{}] }];
-const behaviors = [Behavior.NativeFilter];
const useShouldFilterRefresh = () => {
const isDashboardRefreshing = useSelector<RootState, boolean>(
@@ -92,7 +96,9 @@ const useShouldFilterRefresh = () => {
return !isDashboardRefreshing && isFilterRefreshing;
};
-const FilterValue: FC<FilterControlProps> = ({
+export type FilterValueProps = FilterControlProps;
+
+const FilterValue: FC<FilterValueProps> = ({
dataMaskSelected,
filter,
onFilterSelectionChange,
@@ -106,10 +112,21 @@ const FilterValue: FC<FilterControlProps> = ({
clearAllTrigger,
onClearAllComplete,
}) => {
- const { id, targets, filterType, adhoc_filters, time_range } = filter;
+ const { id, targets, filterType } = filter;
+ const isCustomization = isChartCustomization(filter);
+ const adhocFilters = isCustomization ? undefined : filter.adhoc_filters;
+ const timeRange = isCustomization ? undefined : filter.time_range;
+ const granularitySqla = isCustomization ? undefined : filter.granularity_sqla;
const metadata = getChartMetadataRegistry().get(filterType);
const dependencies = useFilterDependencies(id, dataMaskSelected);
const shouldRefresh = useShouldFilterRefresh();
+
+ const behaviors = useMemo(
+ () => [
+ isCustomization ? Behavior.ChartCustomization : Behavior.NativeFilter,
+ ],
+ [isCustomization],
+ );
const [state, setState] = useState<ChartDataResponseResult[]>([]);
const dashboardId = useSelector<RootState, number>(
state => state.dashboardInfo.id,
@@ -122,12 +139,12 @@ const FilterValue: FC<FilterControlProps> = ({
const [ownState, setOwnState] = useState<JsonObject>({});
const [inViewFirstTime, setInViewFirstTime] = useState(inView);
const inputRef = useRef<HTMLInputElement>(null);
- const [target] = targets;
+ const [target] = targets || [];
const {
datasetId,
column = {},
- }: Partial<{ datasetId: number; column: { name?: string } }> = target;
- const { name: groupby } = column;
+ }: Partial<{ datasetId: number; column: { name?: string } }> = target || {};
+ const groupby = column?.name;
const hasDataSource = !!datasetId;
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -158,17 +175,18 @@ const FilterValue: FC<FilterControlProps> = ({
datasetId,
dependencies,
groupby,
- adhoc_filters,
- time_range,
+ adhoc_filters: adhocFilters,
+ time_range: timeRange,
+ granularity_sqla: granularitySqla,
dashboardId,
});
const filterOwnState = filter.dataMask?.ownState || {};
- if (filter?.cascadeParentIds?.length) {
+ if ((filter.cascadeParentIds ?? []).length) {
// Prevent unnecessary backend requests by validating parent filter selections first
let selectedParentFilterValueCounts = 0;
- filter?.cascadeParentIds?.forEach(pId => {
+ (filter.cascadeParentIds ?? []).forEach(pId => {
const extraFormData = dataMaskSelected?.[pId]?.extraFormData;
if (extraFormData?.filters?.length) {
selectedParentFilterValueCounts += extraFormData.filters.length;
@@ -282,27 +300,39 @@ const FilterValue: FC<FilterControlProps> = ({
);
const setFocusedFilter = useCallback(() => {
- // don't highlight charts in scope if filter was focused programmatically
+ if (isCustomization) {
+ return;
+ }
if (outlinedFilterId !== id) {
dispatchFocusAction(dispatch, id);
}
- }, [dispatch, id, outlinedFilterId]);
+ }, [dispatch, id, outlinedFilterId, isCustomization]);
const unsetFocusedFilter = useCallback(() => {
+ if (isCustomization) {
+ return;
+ }
dispatchFocusAction(dispatch);
if (outlinedFilterId === id) {
dispatch(setDirectPathToChild([]));
}
- }, [dispatch, id, outlinedFilterId]);
+ }, [dispatch, id, outlinedFilterId, isCustomization]);
- const setHoveredFilter = useCallback(
- () => dispatchHoverAction(dispatch, id),
- [dispatch, id],
- );
- const unsetHoveredFilter = useCallback(
- () => dispatchHoverAction(dispatch),
- [dispatch],
- );
+ const setHoveredFilter = useCallback(() => {
+ if (isCustomization) {
+ dispatch(setHoveredChartCustomization(id));
+ } else {
+ dispatchHoverAction(dispatch, id);
+ }
+ }, [dispatch, id, isCustomization]);
+
+ const unsetHoveredFilter = useCallback(() => {
+ if (isCustomization) {
+ dispatch(unsetHoveredChartCustomization());
+ } else {
+ dispatchHoverAction(dispatch);
+ }
+ }, [dispatch, isCustomization]);
const hooks = useMemo(
() => ({
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
similarity index 60%
rename from superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/GroupByFilterCard.tsx
rename to superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
index 37a585b5a4..7145784699 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/GroupByFilterCard.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
@@ -18,8 +18,17 @@
*/
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { t } from '@apache-superset/core';
-import { DataMaskStateWithId, useTruncation } from '@superset-ui/core';
-import { styled, css, useTheme } from '@apache-superset/core/ui';
+import {
+ DataMask,
+ DataMaskStateWithId,
+ Filter,
+ useTruncation,
+ ChartCustomization,
+ NativeFilterTarget,
+ Filters,
+ NativeFilterType,
+} from '@superset-ui/core';
+import { styled, css, useTheme, SupersetTheme } from '@apache-superset/core/ui';
import {
Typography,
Select,
@@ -31,18 +40,28 @@ import {
} from '@superset-ui/core/components';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
-import {
- setPendingChartCustomization,
- loadChartCustomizationData,
-} from 'src/dashboard/actions/chartCustomizationActions';
+import { setPendingChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
import { TooltipWithTruncation } from 'src/dashboard/components/nativeFilters/FilterCard/TooltipWithTruncation';
-import { dispatchChartCustomizationHoverAction } from '../FilterBar/FilterControls/utils';
-import { mergeExtraFormData } from '../utils';
-import { ChartCustomizationItem } from './types';
+import { addDangerToast } from 'src/components/MessageToasts/actions';
+import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
+import { dispatchChartCustomizationHoverAction } from './utils';
+import { mergeExtraFormData } from '../../utils';
+
+interface ColumnApiResponse {
+ column_name?: string;
+ name?: string;
+ verbose_name?: string;
+ filterable?: boolean;
+}
interface GroupByFilterCardProps {
- customizationItem: ChartCustomizationItem;
+ customizationItem: ChartCustomization;
orientation?: 'vertical' | 'horizontal';
+ dataMaskSelected?: DataMaskStateWithId;
+ onFilterSelectionChange?: (
+ filter: Filter | ChartCustomization,
+ dataMask: DataMask,
+ ) => void;
}
const Row = styled.div`
@@ -88,14 +107,10 @@ const FilterTitle = styled(Typography.Text)`
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
display: flex;
align-items: center;
- cursor: pointer;
+ cursor: default;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-
- &:hover {
- color: ${({ theme }) => theme.colorPrimary};
- }
`;
const HorizontalFormItem = styled(FormItem)`
@@ -137,7 +152,6 @@ const HorizontalFormItem = styled(FormItem)`
width: 100%;
}
- /* Allow dropdown to expand beyond form item width */
.ant-select-dropdown {
min-width: 200px !important;
max-width: 400px !important;
@@ -152,7 +166,7 @@ const ToolTipContainer = styled.div`
const RequiredFieldIndicator = () => (
<span
- css={(theme: any) => ({
+ css={(theme: SupersetTheme) => ({
color: theme.colorError,
fontSize: `${theme.fontSizeSM}px`,
paddingLeft: '1px',
@@ -188,53 +202,32 @@ const DescriptionTooltip = ({ description }: { description: string }) => (
);
const GroupByFilterCardContent: FC<{
- customizationItem: ChartCustomizationItem;
+ customizationItem: ChartCustomization;
hidePopover: () => void;
... 10251 lines suppressed ...