From ffe75dcd70148dce7cbf93cb13ff414bb41ffdbb Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Wed, 3 Dec 2025 16:37:01 +0100 Subject: [PATCH 01/20] Chart customization --- .../e2e/dashboard/horizontalFilterBar.test.ts | 1 + .../e2e/dashboard/nativeFilters.test.ts | 3 + .../cypress/e2e/dashboard/utils.ts | 10 +- .../superset-ui-core/src/chart/types/Base.ts | 1 + .../superset-ui-core/src/query/constants.ts | 1 + .../superset-ui-core/src/query/index.ts | 1 + .../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 +- .../src/Multi/Multi.tsx | 127 +- .../src/Multi/controlPanel.ts | 29 +- ...ayerVisibilityCustomizationPlugin.test.tsx | 484 ++++++ ...ckglLayerVisibilityCustomizationPlugin.tsx | 203 +++ .../DeckglLayerVisibility/buildQuery.test.ts | 46 + .../DeckglLayerVisibility/buildQuery.ts | 30 + .../DeckglLayerVisibility/controlPanel.ts | 50 + .../components/DeckglLayerVisibility/index.ts | 43 + .../transformProps.test.ts | 100 ++ .../DeckglLayerVisibility/transformProps.ts | 32 + .../components/DeckglLayerVisibility/types.ts | 46 + .../useDeckLayerMetadata.test.ts | 195 +++ .../useDeckLayerMetadata.ts | 87 + .../DynamicGroupBy/DynamicGroupByPlugin.tsx | 139 ++ .../components/DynamicGroupBy/buildQuery.ts | 30 + .../components/DynamicGroupBy/controlPanel.ts | 80 + .../DynamicGroupBy/images/thumbnail.png | Bin 0 -> 5658 bytes .../components/DynamicGroupBy/index.ts | 44 + .../DynamicGroupBy/transformProps.ts | 51 + .../components/DynamicGroupBy}/types.ts | 74 +- .../TimeColumn/TimeColumnFilterPlugin.tsx | 126 ++ .../components/TimeColumn/buildQuery.ts | 44 + .../components/TimeColumn/controlPanel.ts | 47 + .../TimeColumn/images/thumbnail.png | Bin 0 -> 5658 bytes .../components/TimeColumn/index.ts | 43 + .../components/TimeColumn/transformProps.ts | 60 + .../components/TimeColumn/types.ts | 48 + .../TimeGrain/TimeGrainFilterPlugin.tsx | 144 ++ .../components/TimeGrain/buildQuery.ts | 44 + .../components/TimeGrain/controlPanel.ts | 47 + .../components/TimeGrain/images/thumbnail.png | Bin 0 -> 5658 bytes .../components/TimeGrain/index.ts | 43 + .../components/TimeGrain/transformProps.ts | 51 + .../components/TimeGrain/types.ts | 42 + .../chartCustomizations/components/common.ts | 57 + .../chartCustomizations/components/index.ts | 23 + .../chartCustomizations/components/types.ts | 37 + .../src/components/Chart/chartAction.js | 9 +- superset-frontend/src/constants.ts | 7 + .../actions/chartCustomizationActions.ts | 358 ++-- .../src/dashboard/actions/hydrate.js | 14 +- .../src/dashboard/actions/nativeFilters.ts | 17 +- .../DashboardContainer.test.tsx | 30 +- .../DashboardBuilder/DashboardContainer.tsx | 152 +- .../components/DashboardBuilder/state.ts | 36 +- .../components/FiltersBadge/index.tsx | 64 +- .../components/GroupByBadge/index.tsx | 208 +-- .../components/SyncDashboardState/index.tsx | 5 +- .../components/gridComponents/Chart/Chart.jsx | 6 +- .../ChartCustomizationForm.tsx | 1457 ----------------- .../ChartCustomizationModal.tsx | 700 -------- .../ChartCustomizationTitleContainer.tsx | 184 --- .../ChartCustomizationTitlePane.tsx | 124 -- .../ChartCustomization/groupBySelectors.ts | 262 --- .../ChartCustomization/selectors.ts | 47 - .../useChartCustomizationModal.tsx | 52 - .../nativeFilters/ChartCustomization/utils.ts | 63 - .../ConfigModal/SharedStyles.tsx | 1 - .../FilterBar/ActionButtons/index.tsx | 7 +- .../FilterBar/FilterBar.test.tsx | 38 +- .../FilterBar/FilterBarSettings/index.tsx | 29 +- .../useFilterConfigModal.tsx | 33 +- .../FilterControls/FilterControl.tsx | 287 +--- .../FilterControls/FilterControlShared.tsx | 282 ++++ .../FilterControls/FilterControls.test.tsx | 186 +++ .../FilterControls/FilterControls.tsx | 179 +- .../FilterBar/FilterControls/FilterValue.tsx | 59 +- .../FilterControls}/GroupByFilterCard.tsx | 373 ++--- .../nativeFilters/FilterBar/Horizontal.tsx | 15 +- .../FilterBar/HorizontalFilterBar.test.tsx | 2 + .../nativeFilters/FilterBar/Vertical.tsx | 23 +- .../nativeFilters/FilterBar/index.tsx | 216 ++- .../nativeFilters/FilterBar/state.ts | 67 +- .../nativeFilters/FilterBar/types.ts | 7 + .../FilterBar/useFilterControlFactory.tsx | 5 +- .../nativeFilters/FilterBar/utils.ts | 7 +- .../FilterCard/FilterCard.test.tsx | 8 +- .../ConfigModalContent/ConfigModalContent.tsx | 164 ++ .../CustomizationContentRenderer.tsx | 133 ++ .../FilterContentRenderer.tsx | 136 ++ .../ConfigModalContent/index.ts | 24 + .../ConfigModalSidebar/ConfigModalSidebar.tsx | 191 +++ .../ConfigModalSidebar/ItemSection.tsx | 77 + .../ConfigModalSidebar/index.ts | 22 + .../FiltersConfigModal/DividerConfigForm.tsx | 64 +- .../DraggableFilter.test.tsx | 230 +++ .../FiltersConfigModal/DraggableFilter.tsx | 73 +- .../FiltersConfigForm/DatasetSelect.tsx | 4 +- .../FiltersConfigForm/DefaultValue.tsx | 25 +- .../FiltersConfigForm/FiltersConfigForm.tsx | 408 +++-- .../FiltersConfigForm/constants.ts | 11 + .../FiltersConfigForm/getControlItemsMap.tsx | 14 +- .../FiltersConfigForm/state.ts | 16 +- .../FiltersConfigModal.test.tsx | 184 ++- .../FiltersConfigModal/FiltersConfigModal.tsx | 956 +++++------ .../FiltersConfigModal/ItemTitleContainer.tsx | 194 +++ .../FiltersConfigModal/ItemTitlePane.tsx | 93 ++ .../NativeFiltersModal.test.tsx | 9 +- .../FiltersConfigModal/NewItemDropdown.tsx | 92 ++ .../FiltersConfigModal/hooks/index.ts | 32 + .../hooks/useCustomizationOperations.ts | 127 ++ .../hooks/useFilterOperations.ts | 247 +++ .../hooks/useItemStateManager.ts | 154 ++ .../hooks/useModalSaveLogic.ts | 430 +++++ .../nativeFilters/FiltersConfigModal/state.ts | 16 +- .../transformers/customizationTransformer.ts | 163 ++ .../transformers/filterTransformer.ts | 172 ++ .../FiltersConfigModal/transformers/index.ts | 20 + .../nativeFilters/FiltersConfigModal/types.ts | 80 +- .../FiltersConfigModal/utils.test.ts | 139 ++ .../nativeFilters/FiltersConfigModal/utils.ts | 160 +- .../components/nativeFilters/state.ts | 121 +- .../components/nativeFilters/utils.ts | 26 +- .../src/dashboard/reducers/dashboardInfo.js | 32 +- .../reducers/groupByCustomizations.ts | 198 --- .../src/dashboard/reducers/nativeFilters.ts | 57 +- superset-frontend/src/dashboard/types.ts | 20 +- .../util/activeAllDashboardFilters.ts | 5 +- .../src/dashboard/util/calculateScopes.ts | 75 + .../charts/getFormDataWithExtraFilters.ts | 128 +- .../util/getFormDataWithExtraFilters.test.ts | 114 ++ .../src/dashboard/util/getRelatedCharts.ts | 13 +- .../util/useFilterFocusHighlightStyles.ts | 15 +- 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 + .../integration_tests/dashboards/api_tests.py | 325 ++++ .../test_chart_customizations_dao.py | 339 ++++ 145 files changed, 9862 insertions(+), 5328 deletions(-) create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.test.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/buildQuery.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/controlPanel.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/index.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.test.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/transformProps.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/types.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.test.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/useDeckLayerMetadata.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/DynamicGroupByPlugin.tsx create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/controlPanel.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts create mode 100644 superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts rename superset-frontend/src/{dashboard/components/nativeFilters/ChartCustomization => chartCustomizations/components/DynamicGroupBy}/types.ts (51%) create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/buildQuery.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/controlPanel.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/images/thumbnail.png create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/index.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/transformProps.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeColumn/types.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/TimeGrainFilterPlugin.tsx create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/buildQuery.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/images/thumbnail.png create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts create mode 100644 superset-frontend/src/chartCustomizations/components/TimeGrain/types.ts create mode 100644 superset-frontend/src/chartCustomizations/components/common.ts create mode 100644 superset-frontend/src/chartCustomizations/components/index.ts create mode 100644 superset-frontend/src/chartCustomizations/components/types.ts delete mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/groupBySelectors.ts delete mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/selectors.ts delete mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/useChartCustomizationModal.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControlShared.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.test.tsx rename superset-frontend/src/dashboard/components/nativeFilters/{ChartCustomization => FilterBar/FilterControls}/GroupByFilterCard.tsx (62%) create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalContent/ConfigModalContent.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalContent/CustomizationContentRenderer.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalContent/FilterContentRenderer.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalContent/index.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ConfigModalSidebar.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ItemSection.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/index.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitlePane.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/NewItemDropdown.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/hooks/index.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/hooks/useCustomizationOperations.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/hooks/useFilterOperations.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/hooks/useItemStateManager.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/hooks/useModalSaveLogic.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/customizationTransformer.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/filterTransformer.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/transformers/index.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.test.ts delete mode 100644 superset-frontend/src/dashboard/reducers/groupByCustomizations.ts create mode 100644 superset-frontend/src/dashboard/util/calculateScopes.ts create mode 100644 tests/unit_tests/dashboards/test_chart_customizations_dao.py 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 17fbf587b7e3..f5d71d753b56 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 d70a37013973..c2e8eaf12d9b 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 8ed39621ba39..0822523cff6d 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 f438593dcaf3..15459d11148c 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 0c2df20d3abc..3d91620a1bc6 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 434fe9579899..69ecc0cb925f 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 ac4b19cae55a..a2eb7979c4ee 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]; + targets: Partial[]; 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[]; + 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 +> & { + id: string; + title: string; + description: string; + type: typeof ChartCustomizationType.Divider; +}; + export type AppliedFilter = { values: { filters: Record[]; @@ -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; 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 9a8033a9c612..fcbfd3ed7a75 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; + + 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 3409c61fcc1f..a3de0fb3b910 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> & Partial> & - Partial>; + Partial> & { + /** 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 7a929d506fab..b22de7087852 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 afdfa07dbcfc..c6d2b48e3203 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(); + 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>( {}, ); + const [layerOrder, setLayerOrder] = useState([]); 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 ( - + + + ); }; 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 eaf990e07a5e..fd550decb3b9 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', + '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. Layers render in order—first selected appears at the bottom.', ), 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 000000000000..f3ef1d3389db --- /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(, { + 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(, { + 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(, { + 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(, { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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( + , + { + 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(, { + 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(, { + 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(, { + 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(, { + 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( + , + { + 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 000000000000..5b0ada8a8907 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx @@ -0,0 +1,203 @@ +/** + * 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, 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; + }; +}; + +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), + ], + 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( + 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 ( + +
{t('Loading deck.gl layers...')}
+
+ ); + } + + return ( + + {apiLayers.length === 0 ? ( +
{t('No deck.gl multi layer charts found in this dashboard.')}
+ ) : ( + + {t('Exclude layers (deck.gl)')} + + + + + + + } + > + + + +
+ ); +} diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts new file mode 100644 index 000000000000..6c9eeff43d27 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/buildQuery.ts @@ -0,0 +1,30 @@ +/** + * 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'; + +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 000000000000..39a2211feb34 --- /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 '@superset-ui/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 0000000000000000000000000000000000000000..7afef30bd4e6e4f85723208bf9f429647d03d3e5 GIT binary patch literal 5658 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 literal 0 HcmV?d00001 diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts new file mode 100644 index 000000000000..9df64e66cbeb --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +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/chartCustomizations/components/DynamicGroupBy/transformProps.ts b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts new file mode 100644 index 000000000000..b86d04c0c499 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/transformProps.ts @@ -0,0 +1,51 @@ +/** + * 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 { 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, + 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 ab2d536b03af..46374249da79 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; } -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; +} & 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 000000000000..ec8f65d9719e --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx @@ -0,0 +1,126 @@ +/** + * 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 { ensureIsArray, ExtraFormData, t, tn } 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(defaultValue ?? []); + + const handleChange = (value?: string[] | string | null) => { + const resultValue: string[] = ensureIsArray(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 = ( + + {filterState.validateMessage} + + ); + } + + 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 ( + + + 0} + /> + + + + ); +} 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 000000000000..5a20aef2a882 --- /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/chartCustomizations/components/TimeGrain/controlPanel.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts new file mode 100644 index 000000000000..db9c87fdc94b --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/controlPanel.ts @@ -0,0 +1,47 @@ +/** + * 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 '@superset-ui/core'; + +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 0000000000000000000000000000000000000000..7afef30bd4e6e4f85723208bf9f429647d03d3e5 GIT binary patch literal 5658 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 literal 0 HcmV?d00001 diff --git a/superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts new file mode 100644 index 000000000000..b3cf9b564dda --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/index.ts @@ -0,0 +1,43 @@ +/** + * 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, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +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/chartCustomizations/components/TimeGrain/transformProps.ts b/superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts new file mode 100644 index 000000000000..b86d04c0c499 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/transformProps.ts @@ -0,0 +1,51 @@ +/** + * 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 { 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, + 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 000000000000..611566f0d1d6 --- /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; +} + +export type PluginFilterTimeGrainQueryFormData = QueryFormData & + PluginFilterStylesProps & + PluginFilterTimeGrainCustomizeProps; + +export type PluginFilterTimeGrainProps = PluginFilterStylesProps & { + data: DataRecord[]; + filterState: FilterState; + formData: PluginFilterTimeGrainQueryFormData; + inputRef: RefObject; +} & 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 000000000000..38d98a5ae092 --- /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` + 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/chartCustomizations/components/index.ts b/superset-frontend/src/chartCustomizations/components/index.ts new file mode 100644 index 000000000000..be5ee7956f87 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/index.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + +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/chartCustomizations/components/types.ts b/superset-frontend/src/chartCustomizations/components/types.ts new file mode 100644 index 000000000000..2d9d5c963263 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/types.ts @@ -0,0 +1,37 @@ +/** + * 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 { SetDataMaskHook } from '@superset-ui/core'; +import { FilterBarOrientation } from 'src/dashboard/types'; + +export interface PluginFilterStylesProps { + height: number; + width: number; + orientation?: FilterBarOrientation; + overflow?: boolean; +} + +export interface PluginFilterHooks { + setDataMask: SetDataMaskHook; + setFocusedFilter: () => void; + unsetFocusedFilter: () => void; + setHoveredFilter: () => void; + unsetHoveredFilter: () => void; + setFilterActive: (isActive: boolean) => void; +} diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index 5c2453bedafa..1386adcc12ab 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -503,7 +503,14 @@ export function exploreJSON( }), ); }; - + if (response.name === 'AbortError') { + appendErrorLog('abort'); + const currentChart = getState().charts?.[key]; + if (currentChart?.queryController === controller) { + return dispatch(chartUpdateStopped(key)); + } + return Promise.resolve(); + } return getClientErrorObject(response).then(parsedResponse => { if (response.statusText === 'timeout') { appendErrorLog('timeout'); diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index fddd72288a0e..efd4a516e4c6 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 b81fe8182504..71056f955376 100644 --- a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts +++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts @@ -19,77 +19,60 @@ 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 { omit } from 'lodash'; +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 { selectFilterConfiguration } from 'src/dashboard/components/nativeFilters/state'; +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, - { result: Partial; 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; last_modified_time: number }>, RootState, @@ -100,119 +83,79 @@ export function saveChartCustomization( dispatch: ThunkDispatch, 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 updateChartCustomizations = createUpdateChartCustomizationsApi(id); - const existingFilterState = existingDataMask?.filterState; + try { + const response = await updateChartCustomizations({ + modified: modifiedItems, + deleted: deletedIds, + reordered: reorderedIds, + }); - dispatch(removeDataMask(customizationFilterId)); + const customizationsWithoutScopes = response.result.map( + (customization: ChartCustomization | ChartCustomizationDivider) => + omit(customization, ['chartsInScope', 'tabsInScope']), + ); - const dataMask = { - extraFormData: {}, - filterState: { - value: - existingFilterState?.value || - item.customization?.defaultDataMask?.filterState?.value || - [], - }, - ownState: { - column: item.customization.column, + dispatch( + dashboardInfoChanged({ + metadata: { + ...metadata, + chart_customization_config: customizationsWithoutScopes, }, - }; - - dispatch(updateDataMask(customizationFilterId, dataMask)); - } else { - dispatch(removeDataMask(customizationFilterId)); - } - }); - - const updateDashboard = createUpdateDashboardApi(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 nativeFilters = selectFilterConfiguration(getState()); - 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 allFilters = [...nativeFilters, ...response.result]; - const response = await updateDashboard({ - json_metadata: JSON.stringify(updatedMetadata), + dispatch({ + type: SET_NATIVE_FILTERS_CONFIG_COMPLETE, + filterChanges: allFilters, }); - const lastModifiedTime = response.last_modified_time; - - if (lastModifiedTime) { - dispatch(onSave(lastModifiedTime)); - } + if (resetDataMask) { + const oldConfig = metadata?.chart_customization_config || []; + const oldCustomizationsById = oldConfig.reduce< + Record + >((acc, customization) => { + acc[customization.id] = customization; + return acc; + }, {}); + + const customizationFilterChanges: SaveFilterChangesType = { + modified: response.result 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 +166,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 { - return (dispatch: ThunkDispatch) => { - 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 +189,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 +221,7 @@ export function loadChartCustomizationData( return; } - dispatch(setChartCustomizationDataLoading(itemId, false)); + dispatch(setChartCustomizationDataLoading(itemId, true)); }; } @@ -325,11 +229,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 +284,75 @@ export function clearAllChartCustomizationsFromMetadata() { return clearAllChartCustomizations(); } +export function setInScopeStatusOfCustomizations( + customizationScopes: { + customizationId: string; + chartsInScope: number[]; + tabsInScope: string[]; + }[], +): ThunkAction { + return ( + dispatch: ThunkDispatch, + 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 - | InitializeChartCustomization | SetChartCustomizationDataLoading | SetChartCustomizationData | SetPendingChartCustomization diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index b6f11988e159..c30ed0b94d64 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -17,6 +17,7 @@ * under the License. */ /* eslint-disable camelcase */ +import { omit } from 'lodash'; import { chart } from 'src/components/Chart/chartReducer'; import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities'; import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters'; @@ -225,8 +226,16 @@ export const hydrateDashboard = directPathToChild.push(directLinkComponentId); } + const chartCustomizations = metadata?.chart_customization_config || []; + + const filtersWithoutScopes = ( + metadata?.native_filter_configuration || [] + ).map(filter => omit(filter, ['chartsInScope', 'tabsInScope'])); + + const combinedFilters = [...filtersWithoutScopes, ...chartCustomizations]; + const nativeFilters = getInitialNativeFilterState({ - filterConfig: metadata?.native_filter_configuration || [], + filterConfig: combinedFilters, }); const { chartConfiguration, globalChartConfiguration } = @@ -244,8 +253,6 @@ export const hydrateDashboard = metadata.cross_filters_enabled, ); - const chartCustomizationItems = metadata?.chart_customization_config || []; - return dispatch({ type: HYDRATE_DASHBOARD, data: { @@ -311,7 +318,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 f9d93aa22679..194576a14d65 100644 --- a/superset-frontend/src/dashboard/actions/nativeFilters.ts +++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts @@ -23,7 +23,7 @@ import { makeApi, } from '@superset-ui/core'; import { Dispatch } from 'redux'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, omit } from 'lodash'; import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions'; import { HYDRATE_DASHBOARD } from './hydrate'; import { @@ -77,19 +77,21 @@ export const setFilterConfiguration = filterChanges, }); - const updateFilters = makeApi< - SaveFilterChangesType, - { result: SaveFilterChangesType } - >({ + const updateFilters = makeApi({ method: 'PUT', endpoint: `/api/v1/dashboard/${id}/filters`, }); try { const response = await updateFilters(filterChanges); - dispatch(nativeFiltersConfigChanged(response.result)); + + const filtersWithoutScopes = response.result.map((filter: Filter) => + omit(filter, ['chartsInScope', 'tabsInScope']), + ); + + dispatch(nativeFiltersConfigChanged(filtersWithoutScopes)); dispatch({ type: SET_NATIVE_FILTERS_CONFIG_COMPLETE, - filterChanges: response.result, + filterChanges: filtersWithoutScopes, }); dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters)); } catch (err) { @@ -97,6 +99,7 @@ export const setFilterConfiguration = type: SET_NATIVE_FILTERS_CONFIG_FAIL, filterConfig: filterChanges, }); + throw err; } }; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx index 126b25d3458a..8b02e0a362c3 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx @@ -114,13 +114,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 +148,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 () => { diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index 95e4b120b96e..d7f0be5274e8 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( renderedChartIdsSelector, + shallowEqual, ); - return useMemo(() => renderedChartIds, [JSON.stringify(renderedChartIds)]); -}; - -const useNativeFilterScopes = () => { - const nativeFilters = useSelector( - 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 = ({ topLevelTabs }) => { - const nativeFilterScopes = useNativeFilterScopes(); - const nativeFilters = useSelector( - state => state.nativeFilters?.filters, - ); const dispatch = useDispatch(); const dashboardLayout = useSelector( @@ -114,6 +111,14 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { const dashboardInfo = useSelector( state => state.dashboardInfo, ); + const filterItems = useSelector(selectFilterConfiguration); + const chartCustomizations = useSelector< + RootState, + ChartCustomizationConfiguration + >( + state => state.dashboardInfo?.metadata?.chart_customization_config || [], + shallowEqual, + ); const directPathToChild = useSelector( state => state.dashboardState.directPathToChild, ); @@ -125,6 +130,8 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { useState(false); const prevRenderedChartIds = useRef([]); const prevTabIndexRef = useRef(); + const prevFilterScopesRef = useRef([]); + const prevCustomizationScopesRef = useRef([]); const tabIndex = useMemo(() => { const nextTabIndex = findTabIndexByComponentId({ currentComponent: getRootLevelTabsComponent(dashboardLayout), @@ -150,58 +157,57 @@ const DashboardContainer: FC = ({ 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 c7b7a56fdf42..5a87131cea3a 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -21,13 +21,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { URL_PARAMS } from 'src/constants'; import { getUrlParam } from 'src/utils/urlUtils'; import { RootState } from 'src/dashboard/types'; -import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; +import { + isFeatureEnabled, + FeatureFlag, + ChartCustomization, + ChartCustomizationDivider, +} from '@superset-ui/core'; import { useFilters, useNativeFiltersDataMask, } from '../nativeFilters/FilterBar/state'; +import { useChartCustomizationFromRedux } from '../nativeFilters/state'; import { toggleNativeFiltersBar } from '../../actions/dashboardState'; +const EMPTY_ARRAY: ChartCustomization[] = []; + export const useNativeFilters = () => { const dispatch = useDispatch(); @@ -46,12 +54,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 +100,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/FiltersBadge/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx index 013797e106e9..01bd39e43cb0 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx @@ -164,11 +164,35 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { if (!showIndicators && dashboardIndicators.length > 0) { setDashboardIndicators(indicatorsInitialState); } else if (prevChartStatus !== 'success') { + const hasQueriesResponse = + chart?.queriesResponse != null && + Array.isArray(chart.queriesResponse) && + chart.queriesResponse.length > 0; + const prevHasQueriesResponse = + prevChart?.queriesResponse != null && + Array.isArray(prevChart.queriesResponse) && + prevChart.queriesResponse.length > 0; + + const currentRejected = + hasQueriesResponse && chart?.queriesResponse + ? chart.queriesResponse[0]?.rejected_filters + : undefined; + const prevRejected = + prevHasQueriesResponse && prevChart?.queriesResponse + ? prevChart.queriesResponse[0]?.rejected_filters + : undefined; + const currentApplied = + hasQueriesResponse && chart?.queriesResponse + ? chart.queriesResponse[0]?.applied_filters + : undefined; + const prevApplied = + prevHasQueriesResponse && prevChart?.queriesResponse + ? prevChart.queriesResponse[0]?.applied_filters + : undefined; + if ( - chart?.queriesResponse?.[0]?.rejected_filters !== - prevChart?.queriesResponse?.[0]?.rejected_filters || - chart?.queriesResponse?.[0]?.applied_filters !== - prevChart?.queriesResponse?.[0]?.applied_filters || + currentRejected !== prevRejected || + currentApplied !== prevApplied || dashboardFilters !== prevDashboardFilters || datasources !== prevDatasources ) { @@ -204,11 +228,35 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { if (!showIndicators && nativeIndicators.length > 0) { setNativeIndicators(indicatorsInitialState); } else if (prevChartStatus !== 'success') { + const hasQueriesResponse = + chart?.queriesResponse != null && + Array.isArray(chart.queriesResponse) && + chart.queriesResponse.length > 0; + const prevHasQueriesResponse = + prevChart?.queriesResponse != null && + Array.isArray(prevChart.queriesResponse) && + prevChart.queriesResponse.length > 0; + + const currentRejected = + hasQueriesResponse && chart?.queriesResponse + ? chart.queriesResponse[0]?.rejected_filters + : undefined; + const prevRejected = + prevHasQueriesResponse && prevChart?.queriesResponse + ? prevChart.queriesResponse[0]?.rejected_filters + : undefined; + const currentApplied = + hasQueriesResponse && chart?.queriesResponse + ? chart.queriesResponse[0]?.applied_filters + : undefined; + const prevApplied = + prevHasQueriesResponse && prevChart?.queriesResponse + ? prevChart.queriesResponse[0]?.applied_filters + : undefined; + if ( - chart?.queriesResponse?.[0]?.rejected_filters !== - prevChart?.queriesResponse?.[0]?.rejected_filters || - chart?.queriesResponse?.[0]?.applied_filters !== - prevChart?.queriesResponse?.[0]?.applied_filters || + currentRejected !== prevRejected || + currentApplied !== prevApplied || nativeFilters !== prevNativeFilters || chartLayoutItems !== prevChartLayoutItems || dataMask !== prevDataMask || diff --git a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx b/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx index ad44c17d649b..b06ae3c1c81d 100644 --- a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx @@ -20,13 +20,48 @@ 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, + ChartCustomizationDivider, + 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 { 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 getCustomizationColumn = ( + item: ChartCustomization | any, +): string | null => { + if (item.targets?.[0]?.column?.name) { + return item.targets[0].column.name; + } + if (item.customization?.column) { + const column = item.customization.column; + if (typeof column === 'string') { + return column; + } + if (Array.isArray(column) && column.length > 0) { + return column[0]; + } + } + return null; +}; + const makeSelectChartDataset = (chartId: number) => createSelector( (state: RootState) => state.charts[chartId]?.latestQueryFormData, @@ -155,12 +190,10 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { const triggerRef = useRef(null); const theme = useTheme(); - const chartCustomizationItems = useSelector< - RootState, - ChartCustomizationItem[] - >( - ({ dashboardInfo }) => - dashboardInfo.metadata?.chart_customization_config || [], + const chartCustomizationItems = useChartCustomizationFromRedux(); + + const dataMask = useSelector( + state => state.dataMask, ); // Use memoized selectors for chart data @@ -183,19 +216,31 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { } 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 columnName = getCustomizationColumn(item); - return matchesDataset && hasColumn; + return ( + matchesDataset && + (!!columnName || + item.filterType === 'chart_customization_deckgl_layer_visibility') + ); }); - }, [chartCustomizationItems, chartDataset]); + }, [chartCustomizationItems, chartDataset, chartId]); const effectiveGroupBys = useMemo(() => { if (!chartType || applicableGroupBys.length === 0) { @@ -206,99 +251,12 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { return []; } - if (!chartFormData) { - return applicableGroupBys; - } - - const existingColumns = new Set(); - - 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 applicableGroupBys.filter(groupBy => { + const filterState = dataMask[groupBy.id]?.filterState; + const value = filterState?.value; + return value !== null && value !== undefined; }); - }, [applicableGroupBys, chartType, chartFormData]); + }, [applicableGroupBys, chartType, dataMask]); const groupByCount = effectiveGroupBys.length; @@ -309,26 +267,30 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {

- {t('Chart Customization (%d)', applicableGroupBys.length)} + {t('Chart Customization (%d)', effectiveGroupBys.length)} - {effectiveGroupBys.map(groupBy => ( - -
- {groupBy.customization?.name && - groupBy.customization?.column ? ( - <> - {groupBy.customization.name}: - - {getFilterValueForDisplay(groupBy.customization.column)} - - - ) : ( - groupBy.customization?.name || t('None') - )} -
-
- ))} + {effectiveGroupBys.map(groupBy => { + const filterState = dataMask[groupBy.id]?.filterState; + const displayValue = filterState?.label || filterState?.value; + + return ( + +
+ {groupBy.name && displayValue ? ( + <> + {groupBy.name}: + + {getFilterValueForDisplay(displayValue)} + + + ) : ( + groupBy.name || t('None') + )} +
+
+ ); + })}
diff --git a/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx b/superset-frontend/src/dashboard/components/SyncDashboardState/index.tsx index 51655e057c24..bed9965d2cdd 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> >((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 ebd07fbf177f..9a429f5d223c 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 index 277ad9e6c774..e69de29bb2d1 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx @@ -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>; - item: ChartCustomizationItem; - onUpdate: (updatedItem: ChartCustomizationItem) => void; - removedItems: Record; - allItems?: ChartCustomizationItem[]; -} - -const datasetCache = new Map(); - -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 = ({ - form, - item, - onUpdate, - removedItems, - allItems, -}) => { - const theme = useTheme(); - const customization = useMemo( - () => item.customization || {}, - [item.customization], - ); - - const isRemoved = !!removedItems[item.id]; - - const loadedDatasets = useSelector( - ({ datasources }) => datasources, - ); - const charts = useSelector(({ charts }) => charts); - const globalChartCustomizationItems = useSelector( - selectChartCustomizationItems, - ); - - const chartCustomizationItems = allItems || globalChartCustomizationItems; - - const [metrics, setMetrics] = useState([]); - const [isDefaultValueLoading, setIsDefaultValueLoading] = useState(false); - const [error, setError] = useState(null); - const [datasetDetails, setDatasetDetails] = useState( - 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) => { - 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 ( -
- - - - - - - {t('Dataset')}  - - - } - initialValue={datasetValue} - rules={[ - { required: !isRemoved, message: t('Please select a dataset') }, - ]} - > - { - 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(); - }} - /> - - - - - - -