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..1ad60394d7cf 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/controlPanel.ts @@ -34,26 +34,37 @@ export default { config: { type: 'SelectAsyncControl', multi: true, - label: t('deck.gl charts'), + label: t('deck.gl layers (charts)'), validators: [validateNonEmpty], default: [], description: t( - 'Pick a set of deck.gl charts to layer on top of one another', + 'Select layers in the order you want them stacked. First selected appears at the bottom.Layers let you combine multiple visualizations on one map. Each layer is a saved deck.gl chart (like scatter plots, polygons, or arcs) that displays different data or insights. Stack them to reveal patterns and relationships across your data.', ), dataEndpoint: 'api/v1/chart/?q=(filters:!((col:viz_type,opr:sw,value:deck)))', placeholder: t('Select charts'), onAsyncErrorMessage: t('Error while fetching charts'), - mutator: (data: { - result?: { id: number; slice_name: string }[]; - }) => { + mutator: ( + data: { + result?: { id: number; slice_name: string }[]; + }, + value: number[] | undefined, + ) => { if (!data?.result) { return []; } - return data.result.map(o => ({ - value: o.id, - label: o.slice_name, - })); + const selectedIds = Array.isArray(value) ? value : []; + + return data.result.map(o => { + const selectedIndex = selectedIds.indexOf(o.id); + const indexLabel = + selectedIndex !== -1 ? ` [${selectedIndex + 1}]` : ''; + + return { + value: o.id, + label: `${o.slice_name}${indexLabel}`, + }; + }); }, }, }, diff --git a/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.test.tsx new file mode 100644 index 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..dca9832dd64a --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DeckglLayerVisibility/DeckglLayerVisibilityCustomizationPlugin.tsx @@ -0,0 +1,204 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; +import { t } from '@apache-superset/core'; +import { DataMask, ExtraFormData } from '@superset-ui/core'; +import { useTheme } from '@apache-superset/core/ui'; +import { + Select, + FormItem, + Tooltip, + Icons, + Flex, +} from '@superset-ui/core/components'; +import { useSelector } from 'react-redux'; +import { createSelector } from '@reduxjs/toolkit'; +import { PluginDeckglLayerVisibilityProps } from './types'; +import { useDeckLayerMetadata } from './useDeckLayerMetadata'; +import { FilterPluginStyle } from '../common'; +import { Slice } from 'src/dashboard/types'; + +type SliceEntitiesState = { + sliceEntities?: { + slices: Record; + }; +}; + +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..a09523b6cfb5 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/controlPanel.ts @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ControlPanelConfig } from '@superset-ui/chart-controls'; +import { t } from '@apache-superset/core'; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'groupby', + config: { + type: 'SelectControl', + label: t('Column'), + description: t('Column to group by'), + default: null, + clearable: true, + required: true, + }, + }, + ], + ], + }, + { + label: t('UI Configuration'), + expanded: true, + controlSetRows: [ + [ + { + name: 'canSelectMultiple', + config: { + type: 'CheckboxControl', + label: t('Can select multiple values'), + default: true, + renderTrigger: true, + resetConfig: true, + affectsDataMask: true, + description: t('Allow users to select multiple values'), + }, + }, + ], + [ + { + name: 'enableEmptyFilter', + config: { + type: 'CheckboxControl', + label: t('Chart customization value is required'), + default: false, + renderTrigger: true, + description: t( + 'User must select a value before applying the chart customization', + ), + }, + }, + ], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png new file mode 100644 index 000000000000..7afef30bd4e6 Binary files /dev/null and b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/images/thumbnail.png differ 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..4b81919fca6c --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/DynamicGroupBy/index.ts @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@apache-superset/core'; +import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +export 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..6faf49f1171b --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeColumn/TimeColumnFilterPlugin.tsx @@ -0,0 +1,127 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t, tn } from '@apache-superset/core'; +import { ensureIsArray, ExtraFormData } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/api/core'; +import { useEffect, useState } from 'react'; +import { + FormItem, + type FormItemProps, + Select, +} from '@superset-ui/core/components'; +import { FilterPluginStyle, StatusMessage } from '../common'; +import { PluginFilterTimeColumnProps } from './types'; + +export default function PluginFilterTimeColumn( + props: PluginFilterTimeColumnProps, +) { + const { + data, + formData, + height, + width, + setDataMask, + setHoveredFilter, + unsetHoveredFilter, + setFocusedFilter, + unsetFocusedFilter, + setFilterActive, + filterState, + inputRef, + } = props; + const { defaultValue } = formData; + + const [value, setValue] = useState(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 ( + + + + + + + ); +} 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..49049f70ba2d --- /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 '@apache-superset/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 000000000000..7afef30bd4e6 Binary files /dev/null and b/superset-frontend/src/chartCustomizations/components/TimeGrain/images/thumbnail.png differ 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..8c2c95632c44 --- /dev/null +++ b/superset-frontend/src/chartCustomizations/components/TimeGrain/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 { t } from '@apache-superset/core'; +import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +export 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/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..672cd104206e 100644 --- a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts +++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts @@ -19,77 +19,58 @@ import { AnyAction } from 'redux'; import { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { t } from '@apache-superset/core'; -import { makeApi, getClientErrorObject, DataMask } from '@superset-ui/core'; +import { + makeApi, + getClientErrorObject, + ChartCustomization, + ChartCustomizationDivider, + ColumnOption, + Filter, + Filters, +} from '@superset-ui/core'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { DashboardInfo, RootState } from 'src/dashboard/types'; import { - ChartCustomizationItem, - FilterOption, - ColumnOption, -} from 'src/dashboard/components/nativeFilters/ChartCustomization/types'; -import { triggerQuery } from 'src/components/Chart/chartAction'; -import { removeDataMask, updateDataMask } from 'src/dataMask/actions'; -import { onSave } from './dashboardState'; + removeDataMask, + setDataMaskForFilterChangesComplete, +} from 'src/dataMask/actions'; +import { dashboardInfoChanged } from './dashboardInfo'; +import { + SET_NATIVE_FILTERS_CONFIG_COMPLETE, + SET_IN_SCOPE_STATUS_OF_FILTERS, +} from './nativeFilters'; +import { SaveFilterChangesType } from '../components/nativeFilters/FiltersConfigModal/types'; -const createUpdateDashboardApi = (id: number) => +const createUpdateChartCustomizationsApi = (id: number) => makeApi< - Partial, - { 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 +81,97 @@ 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 existingFilterState = existingDataMask?.filterState; - - dispatch(removeDataMask(customizationFilterId)); - - const dataMask = { - extraFormData: {}, - filterState: { - value: - existingFilterState?.value || - item.customization?.defaultDataMask?.filterState?.value || - [], - }, - ownState: { - column: item.customization.column, - }, - }; - - dispatch(updateDataMask(customizationFilterId, dataMask)); - } else { - dispatch(removeDataMask(customizationFilterId)); - } - }); - - const updateDashboard = createUpdateDashboardApi(id); + const updateChartCustomizations = createUpdateChartCustomizationsApi(id); try { - let parsedMetadata: any = {}; - try { - parsedMetadata = json_metadata ? JSON.parse(json_metadata) : metadata; - } catch (e) { - console.error('Error parsing json_metadata:', e); - parsedMetadata = metadata || {}; - } + const response = await updateChartCustomizations({ + modified: modifiedItems, + deleted: deletedIds, + reordered: reorderedIds, + }); - const updatedMetadata = { - ...parsedMetadata, - native_filter_configuration: ( - parsedMetadata.native_filter_configuration || [] - ).filter( - (item: any) => - !( - item.type === 'CHART_CUSTOMIZATION' && - item.id === 'chart_customization_groupby' - ), - ), - chart_customization_config: simpleItems, - }; + const currentMetadata = getState().dashboardInfo.metadata; + const currentConfig = currentMetadata?.chart_customization_config || []; + + const mergedResult = response.result.map( + (item: ChartCustomization | ChartCustomizationDivider) => { + const existing = currentConfig.find( + (c: ChartCustomization | ChartCustomizationDivider) => + c.id === item.id, + ); + if (!existing) { + return item; + } + return { + ...item, + chartsInScope: + (item as ChartCustomization).chartsInScope ?? + (existing as ChartCustomization).chartsInScope, + tabsInScope: + (item as ChartCustomization).tabsInScope ?? + (existing as ChartCustomization).tabsInScope, + scope: + (item as ChartCustomization).scope ?? + (existing as ChartCustomization).scope, + }; + }, + ); - const response = await updateDashboard({ - json_metadata: JSON.stringify(updatedMetadata), + dispatch({ + type: SET_NATIVE_FILTERS_CONFIG_COMPLETE, + filterChanges: mergedResult, }); - const lastModifiedTime = response.last_modified_time; + dispatch( + dashboardInfoChanged({ + metadata: { + ...currentMetadata, + chart_customization_config: mergedResult, + }, + }), + ); - if (lastModifiedTime) { - dispatch(onSave(lastModifiedTime)); - } + if (resetDataMask) { + const oldConfig = metadata?.chart_customization_config || []; + const oldCustomizationsById = oldConfig.reduce< + Record + >((acc, customization) => { + acc[customization.id] = customization; + return acc; + }, {}); + + const customizationFilterChanges: SaveFilterChangesType = { + modified: modifiedCustomizations as unknown as Filter[], + deleted: deletedIds, + reordered: reorderedIds, + }; - const { dashboardState } = getState(); - const chartIds = dashboardState.sliceIds || []; - if (chartIds.length > 0) { - chartIds.forEach(chartId => { - dispatch(triggerQuery(true, chartId)); - }); + dispatch( + setDataMaskForFilterChangesComplete( + customizationFilterChanges, + oldCustomizationsById as unknown as Filters, + true, + ), + ); } - return response; + return { result: {}, last_modified_time: Date.now() }; } catch (errorObject) { const { error } = await getClientErrorObject(errorObject); dispatch( @@ -223,45 +182,6 @@ export function saveChartCustomization( }; } -export const INITIALIZE_CHART_CUSTOMIZATION = 'INITIALIZE_CHART_CUSTOMIZATION'; -export interface InitializeChartCustomization { - type: typeof INITIALIZE_CHART_CUSTOMIZATION; - chartCustomizationItems: ChartCustomizationItem[]; -} - -export function initializeChartCustomization( - chartCustomizationItems: ChartCustomizationItem[], -): ThunkAction { - 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 +205,12 @@ export const SET_CHART_CUSTOMIZATION_DATA = 'SET_CHART_CUSTOMIZATION_DATA'; export interface SetChartCustomizationData { type: typeof SET_CHART_CUSTOMIZATION_DATA; itemId: string; - data: FilterOption[]; + data: ColumnOption[]; } export function setChartCustomizationData( itemId: string, - data: FilterOption[], + data: ColumnOption[], ): SetChartCustomizationData { return { type: SET_CHART_CUSTOMIZATION_DATA, @@ -317,7 +237,7 @@ export function loadChartCustomizationData( return; } - dispatch(setChartCustomizationDataLoading(itemId, false)); + dispatch(setChartCustomizationDataLoading(itemId, true)); }; } @@ -325,11 +245,11 @@ export const SET_PENDING_CHART_CUSTOMIZATION = 'SET_PENDING_CHART_CUSTOMIZATION'; export interface SetPendingChartCustomization { type: typeof SET_PENDING_CHART_CUSTOMIZATION; - pendingCustomization: ChartCustomizationSavePayload; + pendingCustomization: ChartCustomization; } export function setPendingChartCustomization( - pendingCustomization: ChartCustomizationSavePayload, + pendingCustomization: ChartCustomization, ): SetPendingChartCustomization { return { type: SET_PENDING_CHART_CUSTOMIZATION, @@ -380,9 +300,75 @@ export function clearAllChartCustomizationsFromMetadata() { return clearAllChartCustomizations(); } +export function setInScopeStatusOfCustomizations( + customizationScopes: { + customizationId: string; + chartsInScope: number[]; + tabsInScope: string[]; + }[], +): ThunkAction { + 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..0c545e22400f 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -225,8 +225,12 @@ export const hydrateDashboard = directPathToChild.push(directLinkComponentId); } + const chartCustomizations = metadata?.chart_customization_config || []; + const filters = metadata?.native_filter_configuration || []; + const combinedFilters = [...filters, ...chartCustomizations]; + const nativeFilters = getInitialNativeFilterState({ - filterConfig: metadata?.native_filter_configuration || [], + filterConfig: combinedFilters, }); const { chartConfiguration, globalChartConfiguration } = @@ -244,8 +248,6 @@ export const hydrateDashboard = metadata.cross_filters_enabled, ); - const chartCustomizationItems = metadata?.chart_customization_config || []; - return dispatch({ type: HYDRATE_DASHBOARD, data: { @@ -311,7 +313,6 @@ export const hydrateDashboard = datasetsStatus: dashboardState?.datasetsStatus || ResourceStatus.Loading, chartStates: chartStates || dashboardState?.chartStates || {}, - chartCustomizationItems, }, dashboardLayout, }, diff --git a/superset-frontend/src/dashboard/actions/nativeFilters.ts b/superset-frontend/src/dashboard/actions/nativeFilters.ts index f9d93aa22679..4a628af3f4e2 100644 --- a/superset-frontend/src/dashboard/actions/nativeFilters.ts +++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts @@ -23,6 +23,7 @@ import { makeApi, } from '@superset-ui/core'; import { Dispatch } from 'redux'; +import { RootState } from 'src/dashboard/types'; import { cloneDeep } from 'lodash'; import { setDataMaskForFilterChangesComplete } from 'src/dataMask/actions'; import { HYDRATE_DASHBOARD } from './hydrate'; @@ -64,7 +65,7 @@ const isFilterChangesEmpty = (filterChanges: SaveFilterChangesType) => export const setFilterConfiguration = (filterChanges: SaveFilterChangesType) => - async (dispatch: Dispatch, getState: () => any) => { + async (dispatch: Dispatch, getState: () => RootState) => { if (isFilterChangesEmpty(filterChanges)) { return; } @@ -77,20 +78,17 @@ export const setFilterConfiguration = filterChanges, }); - const updateFilters = makeApi< - SaveFilterChangesType, - { result: SaveFilterChangesType } - >({ + const updateFilters = makeApi({ method: 'PUT', endpoint: `/api/v1/dashboard/${id}/filters`, }); try { const response = await updateFilters(filterChanges); - dispatch(nativeFiltersConfigChanged(response.result)); dispatch({ type: SET_NATIVE_FILTERS_CONFIG_COMPLETE, filterChanges: response.result, }); + dispatch(nativeFiltersConfigChanged(response.result)); dispatch(setDataMaskForFilterChangesComplete(filterChanges, oldFilters)); } catch (err) { dispatch({ @@ -108,7 +106,7 @@ export const setInScopeStatusOfFilters = tabsInScope: string[]; }[], ) => - async (dispatch: Dispatch, getState: () => any) => { + async (dispatch: Dispatch, getState: () => RootState) => { const filters = getState().nativeFilters?.filters; const filtersWithScopes = filterScopes.map(scope => ({ ...filters[scope.filterId], @@ -119,10 +117,9 @@ export const setInScopeStatusOfFilters = type: SET_IN_SCOPE_STATUS_OF_FILTERS, filterConfig: filtersWithScopes, }); - // need to update native_filter_configuration in the dashboard metadata const metadata = cloneDeep(getState().dashboardInfo.metadata); - const filterConfig: FilterConfiguration = - metadata.native_filter_configuration || []; + const filterConfig = + (metadata.native_filter_configuration as FilterConfiguration) || []; const mergedFilterConfig = filterConfig.map(filter => { const filterWithScope = filtersWithScopes.find( scope => scope.id === filter.id, @@ -130,7 +127,11 @@ export const setInScopeStatusOfFilters = if (!filterWithScope) { return filter; } - return { ...filterWithScope, ...filter }; + return { + ...filter, + chartsInScope: filterWithScope.chartsInScope, + tabsInScope: filterWithScope.tabsInScope, + }; }); metadata.native_filter_configuration = mergedFilterConfig; dispatch( diff --git a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx b/superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx similarity index 55% rename from superset-frontend/src/dashboard/components/GroupByBadge/index.tsx rename to superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx index ad44c17d649b..6f7fc988b583 100644 --- a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/CustomizationsBadge/index.tsx @@ -20,13 +20,27 @@ import { memo, useMemo, useState, useRef } from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from '@reduxjs/toolkit'; import { t } from '@apache-superset/core'; +import { ChartCustomization, DataMaskStateWithId } from '@superset-ui/core'; import { styled, useTheme } from '@apache-superset/core/ui'; import { Icons, Badge, Tooltip, Tag } from '@superset-ui/core/components'; import { getFilterValueForDisplay } from '../nativeFilters/utils'; -import { ChartCustomizationItem } from '../nativeFilters/ChartCustomization/types'; +import { extractLabel } from '../nativeFilters/selectors'; +import { useChartCustomizationFromRedux } from '../nativeFilters/state'; import { RootState } from '../../types'; import { isChartWithoutGroupBy } from '../../util/charts/chartTypeLimitations'; +const getCustomizationDataset = ( + item: ChartCustomization | any, +): string | number | null => { + if (item.targets?.[0]?.datasetId !== undefined) { + return item.targets[0].datasetId; + } + if (item.customization?.dataset !== undefined) { + return item.customization.dataset; + } + return null; +}; + const makeSelectChartDataset = (chartId: number) => createSelector( (state: RootState) => state.charts[chartId]?.latestQueryFormData, @@ -47,7 +61,7 @@ const makeSelectChartFormData = (chartId: number) => latestQueryFormData => latestQueryFormData, ); -export interface GroupByBadgeProps { +export interface CustomizationsBadgeProps { chartId: number; } @@ -150,17 +164,15 @@ const GroupByValue = styled.span` overflow: auto; `; -export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { +export const CustomizationsBadge = ({ chartId }: CustomizationsBadgeProps) => { const [tooltipVisible, setTooltipVisible] = useState(false); const triggerRef = useRef(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 @@ -177,28 +189,37 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { const chartFormData = useSelector(selectChartFormData); const chartType = chartFormData?.viz_type; - const applicableGroupBys = useMemo(() => { + const applicableCustomizations = useMemo(() => { if (!chartDataset) { return []; } return chartCustomizationItems.filter(item => { - if (item.removed) return false; + if (item.removed) { + return false; + } - const targetDataset = item.customization?.dataset; - if (!targetDataset) return false; + if (item.chartsInScope && !item.chartsInScope.includes(chartId)) { + return false; + } + + const targetDataset = getCustomizationDataset(item); + if (!targetDataset) { + return false; + } const targetDatasetId = String(targetDataset); const matchesDataset = chartDataset === targetDatasetId; - const hasColumn = item.customization?.column; + const filterState = dataMask[item.id]?.filterState; + const hasValue = extractLabel(filterState) !== null; - return matchesDataset && hasColumn; + return matchesDataset && hasValue; }); - }, [chartCustomizationItems, chartDataset]); + }, [chartCustomizationItems, chartDataset, chartId, dataMask]); - const effectiveGroupBys = useMemo(() => { - if (!chartType || applicableGroupBys.length === 0) { + const effectiveCustomizations = useMemo(() => { + if (!chartType || applicableCustomizations.length === 0) { return []; } @@ -206,129 +227,46 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { return []; } - if (!chartFormData) { - return applicableGroupBys; - } - - const existingColumns = new Set(); - - const extractColumnNames = (columns: unknown[]): void => { - if (Array.isArray(columns)) { - columns.forEach((col: unknown) => { - if (typeof col === 'string') { - existingColumns.add(col); - } else if (col && typeof col === 'object' && 'column_name' in col) { - existingColumns.add((col as { column_name: string }).column_name); - } - }); - } - }; - - const existingGroupBy = Array.isArray(chartFormData.groupby) - ? chartFormData.groupby - : chartFormData.groupby - ? [chartFormData.groupby] - : []; - existingGroupBy.forEach((col: string) => existingColumns.add(col)); - - if (chartFormData.x_axis) { - existingColumns.add(chartFormData.x_axis); - } - - const metrics = chartFormData.metrics || []; - metrics.forEach((metric: any) => { - if (typeof metric === 'string') { - existingColumns.add(metric); - } else if (metric && typeof metric === 'object' && 'column' in metric) { - const metricColumn = metric.column; - if (typeof metricColumn === 'string') { - existingColumns.add(metricColumn); - } else if ( - metricColumn && - typeof metricColumn === 'object' && - 'column_name' in metricColumn - ) { - existingColumns.add(metricColumn.column_name); - } - } - }); - - if (chartFormData.series) { - existingColumns.add(chartFormData.series); - } - if (chartFormData.entity) { - existingColumns.add(chartFormData.entity); - } - if (chartFormData.source) { - existingColumns.add(chartFormData.source); - } - if (chartFormData.target) { - existingColumns.add(chartFormData.target); - } - - if (chartType === 'pivot_table_v2') { - extractColumnNames(chartFormData.groupbyColumns || []); - } - - if (chartType === 'box_plot') { - extractColumnNames(chartFormData.columns || []); - } - - return applicableGroupBys.filter(item => { - if (!item.customization?.column) return false; - - let columnNames: string[] = []; - if (typeof item.customization.column === 'string') { - columnNames = [item.customization.column]; - } else if (Array.isArray(item.customization.column)) { - columnNames = item.customization.column.filter( - col => typeof col === 'string' && col.trim() !== '', - ); - } else if ( - typeof item.customization.column === 'object' && - item.customization.column !== null - ) { - const columnObj = item.customization.column as any; - const columnName = - columnObj.column_name || columnObj.name || String(columnObj); - if (columnName && columnName.trim() !== '') { - columnNames = [columnName]; - } - } - - return columnNames.length > 0; + return applicableCustomizations.filter(customization => { + const filterState = dataMask[customization.id]?.filterState; + const value = filterState?.value; + return value !== null && value !== undefined; }); - }, [applicableGroupBys, chartType, chartFormData]); + }, [applicableCustomizations, chartType, dataMask]); - const groupByCount = effectiveGroupBys.length; + const customizationsCount = effectiveCustomizations.length; - if (groupByCount === 0) { + if (customizationsCount === 0) { return null; } const tooltipContent = (
- {t('Chart Customization (%d)', applicableGroupBys.length)} + {t('Chart Customization (%d)', effectiveCustomizations.length)} - {effectiveGroupBys.map(groupBy => ( - -
- {groupBy.customization?.name && - groupBy.customization?.column ? ( - <> - {groupBy.customization.name}: - - {getFilterValueForDisplay(groupBy.customization.column)} - - - ) : ( - groupBy.customization?.name || t('None') - )} -
-
- ))} + {effectiveCustomizations.map(customization => { + const filterState = dataMask[customization.id]?.filterState; + const displayValue = filterState?.label || filterState?.value; + + return ( + +
+ {customization.name && displayValue ? ( + <> + {customization.name}: + + {getFilterValueForDisplay(displayValue)} + + + ) : ( + customization.name || t('None') + )} +
+
+ ); + })}
@@ -353,14 +291,14 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { > @@ -368,4 +306,4 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { ); }; -export default memo(GroupByBadge); +export default memo(CustomizationsBadge); diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx index 126b25d3458a..886563472ce3 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.test.tsx @@ -43,7 +43,40 @@ jest.mock('src/dashboard/containers/DashboardGrid', () => ({ default: () =>
, })); -function createTestState(overrides = {}) { +const defaultTestFilter = { + id: 'FILTER-1', + name: 'Test Filter', + filterType: 'filter_select', + targets: [ + { + datasetId: 1, + column: { name: 'country' }, + }, + ], + defaultDataMask: { + filterState: { value: null }, + }, + cascadeParentIds: [], + scope: { + rootPath: ['ROOT_ID'], + excluded: [], + }, + controlValues: {}, + type: NativeFilterType.NativeFilter, +}; + +function createTestState(overrides: Record = {}) { + const nativeFilterConfig = ( + overrides.dashboardInfo as + | { metadata?: { native_filter_configuration?: unknown[] } } + | undefined + )?.metadata?.native_filter_configuration ?? [defaultTestFilter]; + + const nativeFiltersMap: Record = {}; + (nativeFilterConfig as Array<{ id: string }>).forEach(filter => { + nativeFiltersMap[filter.id] = filter; + }); + return { ...mockState, dashboardState: { @@ -66,31 +99,16 @@ function createTestState(overrides = {}) { }, }, }, - nativeFilters: { - filters: { - 'FILTER-1': { - id: 'FILTER-1', - name: 'Test Filter', - filterType: 'filter_select', - targets: [ - { - datasetId: 1, - column: { name: 'country' }, - }, - ], - defaultDataMask: { - filterState: { value: null }, - }, - cascadeParentIds: [], - scope: { - rootPath: ['ROOT_ID'], - excluded: [], - }, - controlValues: {}, - type: NativeFilterType.NativeFilter, - }, + dashboardInfo: { + ...mockState.dashboardInfo, + metadata: { + ...mockState.dashboardInfo.metadata, + native_filter_configuration: nativeFilterConfig, }, }, + nativeFilters: { + filters: nativeFiltersMap, + }, ...overrides, }; } @@ -114,13 +132,17 @@ function setupWithStore(overrideState = {}) { } let setInScopeStatusMock: jest.SpyInstance; +const originalSetInScopeStatus = nativeFiltersActions.setInScopeStatusOfFilters; beforeEach(() => { setInScopeStatusMock = jest.spyOn( nativeFiltersActions, 'setInScopeStatusOfFilters', ); - setInScopeStatusMock.mockReturnValue(jest.fn()); + setInScopeStatusMock.mockImplementation(args => { + const thunk = originalSetInScopeStatus(args); + return thunk; + }); }); afterEach(() => { @@ -144,35 +166,37 @@ test('calculates chartsInScope correctly for filters', async () => { ); }); -test('recalculates chartsInScope when filter non-scope properties change', async () => { +test('preserves chartsInScope when filter non-scope properties change', async () => { const { store } = setupWithStore(); await waitFor(() => { expect(setInScopeStatusMock).toHaveBeenCalled(); }); - setInScopeStatusMock.mockClear(); + const stateBeforeUpdate = store.getState(); + const filterBeforeUpdate = + stateBeforeUpdate.nativeFilters.filters['FILTER-1']; + + expect(filterBeforeUpdate.chartsInScope).toEqual([sliceId]); - // Bug scenario: Editing non-scope properties (e.g., "Sort filter values") - // triggers backend save, but response lacks chartsInScope. - // The fix ensures useEffect recalculates chartsInScope anyway. - const initialState = store.getState(); store.dispatch({ type: 'SET_NATIVE_FILTERS_CONFIG_COMPLETE', filterChanges: [ { - ...initialState.nativeFilters.filters['FILTER-1'], + ...filterBeforeUpdate, controlValues: { - ...initialState.nativeFilters.filters['FILTER-1'].controlValues, + ...filterBeforeUpdate.controlValues, sortAscending: false, }, }, ], }); - await waitFor(() => { - expect(setInScopeStatusMock).toHaveBeenCalled(); - }); + const stateAfterUpdate = store.getState(); + const filterAfterUpdate = stateAfterUpdate.nativeFilters.filters['FILTER-1']; + + expect(filterAfterUpdate.chartsInScope).toEqual([sliceId]); + expect(filterAfterUpdate.controlValues?.sortAscending).toBe(false); }); test('handles multiple filters with different scopes', async () => { @@ -180,8 +204,28 @@ test('handles multiple filters with different scopes', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout; + const multipleFilters = [ + { + id: 'FILTER-1', + name: 'Filter 1', + filterType: 'filter_select', + targets: [{ datasetId: 1, column: { name: 'country' } }], + scope: { rootPath: ['ROOT_ID'], excluded: [] }, + controlValues: {}, + type: NativeFilterType.NativeFilter, + }, + { + id: 'FILTER-2', + name: 'Filter 2', + filterType: 'filter_select', + targets: [{ datasetId: 1, column: { name: 'region' } }], + scope: { rootPath: ['ROOT_ID'], excluded: [19] }, + controlValues: {}, + type: NativeFilterType.NativeFilter, + }, + ]; + const stateWithMultipleFilters = { - ...mockState, dashboardState: { ...mockState.dashboardState, sliceIds: [18, 19], @@ -204,26 +248,17 @@ test('handles multiple filters with different scopes', async () => { }, }, }, + dashboardInfo: { + ...mockState.dashboardInfo, + metadata: { + ...mockState.dashboardInfo.metadata, + native_filter_configuration: multipleFilters, + }, + }, nativeFilters: { filters: { - 'FILTER-1': { - id: 'FILTER-1', - name: 'Filter 1', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'country' } }], - scope: { rootPath: ['ROOT_ID'], excluded: [] }, - controlValues: {}, - type: NativeFilterType.NativeFilter, - }, - 'FILTER-2': { - id: 'FILTER-2', - name: 'Filter 2', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'region' } }], - scope: { rootPath: ['ROOT_ID'], excluded: [19] }, - controlValues: {}, - type: NativeFilterType.NativeFilter, - }, + 'FILTER-1': multipleFilters[0], + 'FILTER-2': multipleFilters[1], }, }, }; @@ -250,20 +285,24 @@ test('handles multiple filters with different scopes', async () => { test('handles filters with no charts in scope', async () => { const stateWithExcludedFilter = createTestState({ - nativeFilters: { - filters: { - 'FILTER-1': { - id: 'FILTER-1', - name: 'Excluded Filter', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'country' } }], - scope: { - rootPath: ['ROOT_ID'], - excluded: [sliceId], + dashboardInfo: { + ...mockState.dashboardInfo, + metadata: { + ...mockState.dashboardInfo.metadata, + native_filter_configuration: [ + { + id: 'FILTER-1', + name: 'Excluded Filter', + filterType: 'filter_select', + targets: [{ datasetId: 1, column: { name: 'country' } }], + scope: { + rootPath: ['ROOT_ID'], + excluded: [sliceId], + }, + controlValues: {}, + type: NativeFilterType.NativeFilter, }, - controlValues: {}, - type: NativeFilterType.NativeFilter, - }, + ], }, }, }); @@ -286,8 +325,12 @@ test('handles filters with no charts in scope', async () => { test('does not dispatch when there are no filters', () => { const stateWithoutFilters = createTestState({ - nativeFilters: { - filters: {}, + dashboardInfo: { + ...mockState.dashboardInfo, + metadata: { + ...mockState.dashboardInfo.metadata, + native_filter_configuration: [], + }, }, }); @@ -301,8 +344,17 @@ test('calculates tabsInScope for filters with tab-scoped charts', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { CHART_ID: _removed, ...cleanLayout } = baseDashboardLayout; + const tabScopedFilter = { + id: 'FILTER-TAB-SCOPED', + name: 'Tab Scoped Filter', + filterType: 'filter_select', + targets: [{ datasetId: 1, column: { name: 'region' } }], + scope: { rootPath: ['ROOT_ID'], excluded: [22] }, + controlValues: {}, + type: NativeFilterType.NativeFilter, + }; + const stateWithTabs = { - ...mockState, dashboardState: { ...mockState.dashboardState, sliceIds: [20, 21, 22], @@ -356,17 +408,16 @@ test('calculates tabsInScope for filters with tab-scoped charts', async () => { }, }, }, + dashboardInfo: { + ...mockState.dashboardInfo, + metadata: { + ...mockState.dashboardInfo.metadata, + native_filter_configuration: [tabScopedFilter], + }, + }, nativeFilters: { filters: { - 'FILTER-TAB-SCOPED': { - id: 'FILTER-TAB-SCOPED', - name: 'Tab Scoped Filter', - filterType: 'filter_select', - targets: [{ datasetId: 1, column: { name: 'region' } }], - scope: { rootPath: ['ROOT_ID'], excluded: [22] }, - controlValues: {}, - type: NativeFilterType.NativeFilter, - }, + 'FILTER-TAB-SCOPED': tabScopedFilter, }, }, }; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index 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..a6de9e390c8a 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -26,6 +26,7 @@ import { useFilters, useNativeFiltersDataMask, } from '../nativeFilters/FilterBar/state'; +import { useChartCustomizationFromRedux } from '../nativeFilters/state'; import { toggleNativeFiltersBar } from '../../actions/dashboardState'; export const useNativeFilters = () => { @@ -46,12 +47,22 @@ export const useNativeFilters = () => { const filters = useFilters(); const filterValues = useMemo(() => Object.values(filters), [filters]); const expandFilters = getUrlParam(URL_PARAMS.expandFilters); + const chartCustomizations = useChartCustomizationFromRedux(); const nativeFiltersEnabled = - showNativeFilters && (canEdit || (!canEdit && filterValues.length !== 0)); + showNativeFilters && + (canEdit || + (!canEdit && + (filterValues.length !== 0 || chartCustomizations.length !== 0))); const requiredFirstFilter = useMemo( - () => filterValues.filter(filter => filter.requiredFirst), + () => + filterValues.filter( + filter => + 'requiredFirst' in filter && + filter.requiredFirst === true && + filter.filterType !== 'filter_time', + ), [filterValues], ); const dataMask = useNativeFiltersDataMask(); @@ -82,13 +93,21 @@ export const useNativeFilters = () => { (isFeatureEnabled(FeatureFlag.FilterBarClosedByDefault) && expandFilters === null) || expandFilters === false || - (filterValues.length === 0 && nativeFiltersEnabled) + (filterValues.length === 0 && + chartCustomizations.length === 0 && + nativeFiltersEnabled) ) { dispatch(toggleNativeFiltersBar(false)); } else { dispatch(toggleNativeFiltersBar(true)); } - }, [dispatch, filterValues.length, expandFilters, nativeFiltersEnabled]); + }, [ + dispatch, + filterValues.length, + chartCustomizations.length, + expandFilters, + nativeFiltersEnabled, + ]); useEffect(() => { if (showDashboard) { diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 4b98c0f14045..4e76ec6fe615 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -34,7 +34,7 @@ import { useSelector } from 'react-redux'; import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls'; import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types'; import FiltersBadge from 'src/dashboard/components/FiltersBadge'; -import GroupByBadge from 'src/dashboard/components/GroupByBadge'; +import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge'; import { RootState } from 'src/dashboard/types'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; @@ -297,7 +297,7 @@ const SliceHeader = forwardRef( )} {!uiConfig.hideChartControls && ( - + )} {!uiConfig.hideChartControls && ( 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 deleted file mode 100644 index 277ad9e6c774..000000000000 --- a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx +++ /dev/null @@ -1,1457 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { - FC, - useEffect, - useMemo, - useState, - useRef, - useCallback, - ReactNode, -} from 'react'; -import { useSelector } from 'react-redux'; -import { t } from '@apache-superset/core'; -import { styled, css, useTheme } from '@apache-superset/core/ui'; -import { debounce } from 'lodash'; -import { DatasourcesState, ChartsState, RootState } from 'src/dashboard/types'; -import { - Constants, - FormItem, - Input, - Select, - Collapse, - InfoTooltip, - Loading, - Radio, - type SelectValue, - FormInstance, - Checkbox, - CheckboxChangeEvent, -} from '@superset-ui/core/components'; -import { DatasetSelectLabel } from 'src/features/datasets/DatasetSelectLabel'; -import { CollapsibleControl } from '../FiltersConfigModal/FiltersConfigForm/CollapsibleControl'; -import DatasetSelect from '../FiltersConfigModal/FiltersConfigForm/DatasetSelect'; -import { mostUsedDataset } from '../FiltersConfigModal/FiltersConfigForm/utils'; -import { ChartCustomizationItem } from './types'; -import { selectChartCustomizationItems } from './selectors'; - -const { TextArea } = Input; - -interface Metric { - metric_name: string; - verbose_name?: string; -} - -interface DatasetDetails { - id: number; - table_name: string; - schema?: string; - database?: { database_name: string }; -} - -interface ApiError { - message?: string; - error?: string; -} - -interface DatasetColumn { - column_name?: string; - name?: string; - verbose_name?: string; - filterable?: boolean; -} - -interface DatasetData { - id: number; - table_name: string; - schema?: string; - database?: { database_name: string }; - metrics?: Metric[]; - columns?: DatasetColumn[]; -} - -interface CachedDataset { - data: DatasetData; - timestamp: number; -} - -interface ColumnOption { - label: string; - value: string; -} - -interface Props { - form: FormInstance>; - 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(); - }} - /> - - - - - - -