From 97518544eeb583133743c2afc98efd5d186c0630 Mon Sep 17 00:00:00 2001 From: Levis Mbote <111055098+LevisNgigi@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:37:57 +0300 Subject: [PATCH] feat(dashboard): chart customization/dynamic group by in dashboards (#33831) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: GitHub Action Co-authored-by: amaannawab923 --- .../dashboard/shared_dashboard_functions.ts | 1 + .../src/components/Icons/AntdEnhanced.tsx | 2 + .../actions/chartCustomizationActions.ts | 390 +++++ .../src/dashboard/actions/dashboardInfo.ts | 78 +- .../src/dashboard/actions/dashboardLayout.js | 2 +- .../src/dashboard/actions/hydrate.js | 3 + .../src/dashboard/actions/nativeFilters.ts | 28 + .../DashboardBuilder/DashboardContainer.tsx | 13 +- .../components/GroupByBadge/index.tsx | 350 ++++ .../components/SliceHeader/index.tsx | 4 + .../components/gridComponents/Chart/Chart.jsx | 5 + .../ChartCustomizationForm.tsx | 1456 +++++++++++++++++ .../ChartCustomizationModal.tsx | 699 ++++++++ .../ChartCustomizationTitleContainer.tsx | 183 +++ .../ChartCustomizationTitlePane.tsx | 123 ++ .../ChartCustomization/GroupByFilterCard.tsx | 647 ++++++++ .../ChartCustomization/groupBySelectors.ts | 262 +++ .../ChartCustomization/selectors.ts | 45 + .../nativeFilters/ChartCustomization/types.ts | 90 + .../useChartCustomizationModal.tsx | 52 + .../nativeFilters/ChartCustomization/utils.ts | 63 + .../ConfigModal/BaseConfigModal.tsx | 148 ++ .../nativeFilters/ConfigModal/ModalFooter.tsx | 187 +++ .../ConfigModal/SharedStyles.tsx | 110 ++ .../FilterBar/ActionButtons/index.tsx | 36 +- .../FilterBar/CrossFilters/CrossFilter.tsx | 3 +- .../FilterBar/CrossFilters/Vertical.tsx | 13 +- .../CrossFilters/VerticalCollapse.tsx | 117 +- .../FilterBar/CrossFilters/selectors.ts | 4 + .../FilterBar/FilterBar.test.tsx | 4 +- .../FilterBarSettings.test.tsx | 20 +- .../FilterBar/FilterBarSettings/index.tsx | 32 +- .../FilterControls/FilterControls.tsx | 230 ++- .../FilterBar/FilterControls/utils.ts | 13 + .../FilterBar/Header/Header.test.tsx | 4 +- .../nativeFilters/FilterBar/Header/index.tsx | 2 +- .../nativeFilters/FilterBar/Horizontal.tsx | 12 +- .../nativeFilters/FilterBar/Vertical.tsx | 122 +- .../nativeFilters/FilterBar/index.tsx | 212 ++- .../nativeFilters/FilterBar/utils.ts | 43 +- .../FiltersConfigForm/CollapsibleControl.tsx | 2 +- .../FiltersConfigForm/DatasetSelect.tsx | 78 +- .../FiltersConfigModal/FiltersConfigModal.tsx | 70 +- .../components/nativeFilters/state.ts | 12 +- .../src/dashboard/reducers/dashboardInfo.js | 99 ++ .../reducers/groupByCustomizations.ts | 198 +++ .../src/dashboard/reducers/nativeFilters.ts | 40 +- superset-frontend/src/dashboard/types.ts | 10 + .../util/charts/chartTypeLimitations.ts | 128 ++ .../charts/getFormDataWithExtraFilters.ts | 372 ++++- .../src/dashboard/util/getRelatedCharts.ts | 31 + .../util/useFilterFocusHighlightStyles.ts | 54 +- superset-frontend/src/dataMask/actions.ts | 14 + superset-frontend/src/dataMask/reducer.ts | 47 + superset-frontend/src/pages/Chart/index.tsx | 1 + superset-frontend/src/views/store.ts | 2 + superset/dashboards/schemas.py | 1 + 57 files changed, 6704 insertions(+), 263 deletions(-) create mode 100644 superset-frontend/src/dashboard/actions/chartCustomizationActions.ts create mode 100644 superset-frontend/src/dashboard/components/GroupByBadge/index.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationModal.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitleContainer.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationTitlePane.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/GroupByFilterCard.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/groupBySelectors.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/selectors.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/types.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/useChartCustomizationModal.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/utils.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/BaseConfigModal.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/ModalFooter.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/ConfigModal/SharedStyles.tsx create mode 100644 superset-frontend/src/dashboard/reducers/groupByCustomizations.ts create mode 100644 superset-frontend/src/dashboard/util/charts/chartTypeLimitations.ts diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts index 595bf45da09..0ece4d9ef50 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/shared_dashboard_functions.ts @@ -87,6 +87,7 @@ export function prepareDashboardFilters( if (dashboardId) { const jsonMetadata = { native_filter_configuration: allFilters, + chart_customization_config: [], timed_refresh_immune_slices: [], expanded_slices: {}, refresh_frequency: 0, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index fe5ae1c880d..a079b54c16d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -144,6 +144,7 @@ import { GoogleOutlined, DesktopOutlined, FormatPainterOutlined, + GroupOutlined, ExportOutlined, CompressOutlined, HistoryOutlined, @@ -221,6 +222,7 @@ const AntdIcons = { FunctionOutlined, GithubOutlined, GoogleOutlined, + GroupOutlined, HighlightOutlined, InfoCircleOutlined, InfoCircleFilled, diff --git a/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts new file mode 100644 index 00000000000..309bd3c3c04 --- /dev/null +++ b/superset-frontend/src/dashboard/actions/chartCustomizationActions.ts @@ -0,0 +1,390 @@ +/** + * 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 { AnyAction } from 'redux'; +import { ThunkAction, ThunkDispatch } from 'redux-thunk'; +import { makeApi, t, getClientErrorObject, DataMask } 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'; + +const createUpdateDashboardApi = (id: number) => + makeApi< + Partial, + { result: Partial; last_modified_time: number } + >({ + method: 'PUT', + endpoint: `/api/v1/dashboard/${id}`, + }); + +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[], +) { + return { type: SAVE_CHART_CUSTOMIZATION_COMPLETE, chartCustomization }; +} + +export function saveChartCustomization( + chartCustomizationItems: ChartCustomizationSavePayload[], +): ThunkAction< + Promise<{ result: Partial; last_modified_time: number }>, + RootState, + null, + AnyAction +> { + return async function ( + 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 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)); + }); + + 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); + + 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 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 response = await updateDashboard({ + json_metadata: JSON.stringify(updatedMetadata), + }); + + const lastModifiedTime = response.last_modified_time; + + if (lastModifiedTime) { + dispatch(onSave(lastModifiedTime)); + } + + const { dashboardState } = getState(); + const chartIds = dashboardState.sliceIds || []; + if (chartIds.length > 0) { + chartIds.forEach(chartId => { + dispatch(triggerQuery(true, chartId)); + }); + } + + return response; + } catch (errorObject) { + const { error } = await getClientErrorObject(errorObject); + dispatch( + addDangerToast(error || t('Failed to save chart customization')), + ); + throw errorObject; + } + }; +} + +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 { + type: typeof SET_CHART_CUSTOMIZATION_DATA_LOADING; + itemId: string; + isLoading: boolean; +} + +export function setChartCustomizationDataLoading( + itemId: string, + isLoading: boolean, +): SetChartCustomizationDataLoading { + return { + type: SET_CHART_CUSTOMIZATION_DATA_LOADING, + itemId, + isLoading, + }; +} + +export const SET_CHART_CUSTOMIZATION_DATA = 'SET_CHART_CUSTOMIZATION_DATA'; +export interface SetChartCustomizationData { + type: typeof SET_CHART_CUSTOMIZATION_DATA; + itemId: string; + data: FilterOption[]; +} + +export function setChartCustomizationData( + itemId: string, + data: FilterOption[], +): SetChartCustomizationData { + return { + type: SET_CHART_CUSTOMIZATION_DATA, + itemId, + data, + }; +} + +export function loadChartCustomizationData( + itemId: string, + datasetId: string, + columnName: string | string[], +): ThunkAction, RootState, null, AnyAction> { + return async (dispatch: ThunkDispatch) => { + if (!datasetId || !columnName) { + return; + } + + const actualColumnName = Array.isArray(columnName) + ? columnName[0] + : columnName; + + if (!actualColumnName) { + return; + } + + dispatch(setChartCustomizationDataLoading(itemId, false)); + }; +} + +export const SET_PENDING_CHART_CUSTOMIZATION = + 'SET_PENDING_CHART_CUSTOMIZATION'; +export interface SetPendingChartCustomization { + type: typeof SET_PENDING_CHART_CUSTOMIZATION; + pendingCustomization: ChartCustomizationSavePayload; +} + +export function setPendingChartCustomization( + pendingCustomization: ChartCustomizationSavePayload, +): SetPendingChartCustomization { + return { + type: SET_PENDING_CHART_CUSTOMIZATION, + pendingCustomization, + }; +} + +export const CLEAR_PENDING_CHART_CUSTOMIZATION = + 'CLEAR_PENDING_CHART_CUSTOMIZATION'; +export interface ClearPendingChartCustomization { + type: typeof CLEAR_PENDING_CHART_CUSTOMIZATION; + itemId: string; +} + +export function clearPendingChartCustomization( + itemId: string, +): ClearPendingChartCustomization { + return { + type: CLEAR_PENDING_CHART_CUSTOMIZATION, + itemId, + }; +} + +export const CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS = + 'CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS'; +export interface ClearAllPendingChartCustomizations { + type: typeof CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS; +} + +export function clearAllPendingChartCustomizations(): ClearAllPendingChartCustomizations { + return { + type: CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS, + }; +} + +export const CLEAR_ALL_CHART_CUSTOMIZATIONS = 'CLEAR_ALL_CHART_CUSTOMIZATIONS'; +export interface ClearAllChartCustomizations { + type: typeof CLEAR_ALL_CHART_CUSTOMIZATIONS; +} + +export function clearAllChartCustomizations(): ClearAllChartCustomizations { + return { + type: CLEAR_ALL_CHART_CUSTOMIZATIONS, + }; +} + +export function clearAllChartCustomizationsFromMetadata() { + return clearAllChartCustomizations(); +} + +export type AnyChartCustomizationAction = + | ReturnType + | InitializeChartCustomization + | SetChartCustomizationDataLoading + | SetChartCustomizationData + | SetPendingChartCustomization + | ClearPendingChartCustomization + | ClearAllPendingChartCustomizations + | ClearAllChartCustomizations; diff --git a/superset-frontend/src/dashboard/actions/dashboardInfo.ts b/superset-frontend/src/dashboard/actions/dashboardInfo.ts index 1315a962334..63570638ea9 100644 --- a/superset-frontend/src/dashboard/actions/dashboardInfo.ts +++ b/superset-frontend/src/dashboard/actions/dashboardInfo.ts @@ -17,7 +17,7 @@ * under the License. */ import { Dispatch } from 'redux'; -import { makeApi, t, getErrorText } from '@superset-ui/core'; +import { makeApi, t, getClientErrorObject } from '@superset-ui/core'; import { addDangerToast } from 'src/components/MessageToasts/actions'; import { ChartConfiguration, @@ -28,6 +28,15 @@ import { } from 'src/dashboard/types'; import { onSave } from './dashboardState'; +const createUpdateDashboardApi = (id: number) => + makeApi< + Partial, + { result: Partial; last_modified_time: number } + >({ + method: 'PUT', + endpoint: `/api/v1/dashboard/${id}`, + }); + export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED'; export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED'; @@ -60,14 +69,7 @@ export const saveChartConfiguration = }); const { id, metadata } = getState().dashboardInfo; - // TODO extract this out when makeApi supports url parameters - const updateDashboard = makeApi< - Partial, - { result: DashboardInfo } - >({ - method: 'PUT', - endpoint: `/api/v1/dashboard/${id}`, - }); + const updateDashboard = createUpdateDashboardApi(id); try { const response = await updateDashboard({ @@ -81,7 +83,7 @@ export const saveChartConfiguration = }); dispatch( dashboardInfoChanged({ - metadata: JSON.parse(response.result.json_metadata), + metadata: JSON.parse(response.result.json_metadata || '{}'), }), ); dispatch({ @@ -116,13 +118,7 @@ export function setCrossFiltersEnabled(crossFiltersEnabled: boolean) { export function saveFilterBarOrientation(orientation: FilterBarOrientation) { return async (dispatch: Dispatch, getState: () => RootState) => { const { id, metadata } = getState().dashboardInfo; - const updateDashboard = makeApi< - Partial, - { result: Partial; last_modified_time: number } - >({ - method: 'PUT', - endpoint: `/api/v1/dashboard/${id}`, - }); + const updateDashboard = createUpdateDashboardApi(id); try { const response = await updateDashboard({ json_metadata: JSON.stringify({ @@ -142,23 +138,33 @@ export function saveFilterBarOrientation(orientation: FilterBarOrientation) { dispatch(onSave(lastModifiedTime)); } } catch (errorObject) { - const errorText = await getErrorText(errorObject, 'dashboard'); - dispatch(addDangerToast(errorText)); + const { error } = await getClientErrorObject(errorObject); + dispatch( + addDangerToast( + t( + 'Sorry, there was an error saving this dashboard: %s', + error || 'Bad Request', + ), + ), + ); throw errorObject; } }; } export function saveCrossFiltersSetting(crossFiltersEnabled: boolean) { - return async (dispatch: Dispatch, getState: () => RootState) => { + return async function saveCrossFiltersSettingThunk( + dispatch: Dispatch, + getState: () => RootState, + ) { const { id, metadata } = getState().dashboardInfo; - const updateDashboard = makeApi< - Partial, - { result: Partial; last_modified_time: number } - >({ - method: 'PUT', - endpoint: `/api/v1/dashboard/${id}`, - }); + + const previousCrossFiltersEnabled = + getState().dashboardInfo.crossFiltersEnabled; + + dispatch(setCrossFiltersEnabled(crossFiltersEnabled)); + const updateDashboard = createUpdateDashboardApi(id); + try { const response = await updateDashboard({ json_metadata: JSON.stringify({ @@ -166,19 +172,29 @@ export function saveCrossFiltersSetting(crossFiltersEnabled: boolean) { cross_filters_enabled: crossFiltersEnabled, }), }); + const updatedDashboard = response.result; const lastModifiedTime = response.last_modified_time; + if (updatedDashboard.json_metadata) { const metadata = JSON.parse(updatedDashboard.json_metadata); dispatch(setCrossFiltersEnabled(metadata.cross_filters_enabled)); } + if (lastModifiedTime) { dispatch(onSave(lastModifiedTime)); } - } catch (errorObject) { - const errorText = await getErrorText(errorObject, 'dashboard'); - dispatch(addDangerToast(errorText)); - throw errorObject; + + dispatch( + dashboardInfoChanged({ + metadata: JSON.parse(response.result.json_metadata || '{}'), + }), + ); + return response; + } catch (err) { + dispatch(setCrossFiltersEnabled(previousCrossFiltersEnabled)); + dispatch(addDangerToast(t('Failed to save cross-filters setting'))); + throw err; } }; } diff --git a/superset-frontend/src/dashboard/actions/dashboardLayout.js b/superset-frontend/src/dashboard/actions/dashboardLayout.js index b771ae20291..42759a8e9c4 100644 --- a/superset-frontend/src/dashboard/actions/dashboardLayout.js +++ b/superset-frontend/src/dashboard/actions/dashboardLayout.js @@ -159,7 +159,7 @@ export function resizeComponent({ id, width, height }) { }; } -// Drag and drop -------------------------------------------------------------- +// Drag and Drop -------------------------------------------------------------- export const MOVE_COMPONENT = 'MOVE_COMPONENT'; const moveComponent = setUnsavedChangesAfterAction(dropResult => ({ type: MOVE_COMPONENT, diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index fe41d8adedf..9a9ebb60462 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -244,6 +244,8 @@ export const hydrateDashboard = metadata.cross_filters_enabled, ); + const chartCustomizationItems = metadata?.chart_customization_config || []; + return dispatch({ type: HYDRATE_DASHBOARD, data: { @@ -308,6 +310,7 @@ export const hydrateDashboard = activeTabs: activeTabs || dashboardState?.activeTabs || [], datasetsStatus: dashboardState?.datasetsStatus || ResourceStatus.Loading, + chartCustomizationItems, }, dashboardLayout, }, diff --git a/superset-frontend/src/dashboard/actions/nativeFilters.ts b/superset-frontend/src/dashboard/actions/nativeFilters.ts index 86406cef2f5..f9d93aa2267 100644 --- a/superset-frontend/src/dashboard/actions/nativeFilters.ts +++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts @@ -196,6 +196,32 @@ export function unsetHoveredNativeFilter(): UnsetHoveredNativeFilter { }; } +export const SET_HOVERED_CHART_CUSTOMIZATION = + 'SET_HOVERED_CHART_CUSTOMIZATION'; +export interface SetHoveredChartCustomization { + type: typeof SET_HOVERED_CHART_CUSTOMIZATION; + id: string; +} +export const UNSET_HOVERED_CHART_CUSTOMIZATION = + 'UNSET_HOVERED_CHART_CUSTOMIZATION'; +export interface UnsetHoveredChartCustomization { + type: typeof UNSET_HOVERED_CHART_CUSTOMIZATION; +} + +export function setHoveredChartCustomization( + id: string, +): SetHoveredChartCustomization { + return { + type: SET_HOVERED_CHART_CUSTOMIZATION, + id, + }; +} +export function unsetHoveredChartCustomization(): UnsetHoveredChartCustomization { + return { + type: UNSET_HOVERED_CHART_CUSTOMIZATION, + }; +} + export const UPDATE_CASCADE_PARENT_IDS = 'UPDATE_CASCADE_PARENT_IDS'; export interface UpdateCascadeParentIds { type: typeof UPDATE_CASCADE_PARENT_IDS; @@ -223,4 +249,6 @@ export type AnyFilterAction = | UnsetFocusedNativeFilter | SetHoveredNativeFilter | UnsetHoveredNativeFilter + | SetHoveredChartCustomization + | UnsetHoveredChartCustomization | UpdateCascadeParentIds; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index 253c17dc422..9ff41a99c29 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -152,7 +152,10 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { return; } const scopes = nativeFilterScopes.map(filterScope => { - if (filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX)) { + if ( + filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX) || + filterScope.id.startsWith('chart_customization_') + ) { return { filterId: filterScope.id, tabsInScope: [], @@ -164,6 +167,14 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { item => item?.type === CHART_TYPE, ); + if (!filterScope.scope || !Array.isArray(filterScope.scope.excluded)) { + return { + filterId: filterScope.id, + tabsInScope: [], + chartsInScope: [], + }; + } + const chartsInScope: number[] = getChartIdsInFilterScope( filterScope.scope, chartIds, diff --git a/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx b/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx new file mode 100644 index 00000000000..6c21d033ae8 --- /dev/null +++ b/superset-frontend/src/dashboard/components/GroupByBadge/index.tsx @@ -0,0 +1,350 @@ +/** + * 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 { memo, useMemo, useState, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { styled, t, useTheme } from '@superset-ui/core'; +import { Icons, Badge, Tooltip, Tag } from '@superset-ui/core/components'; +import { getFilterValueForDisplay } from '../nativeFilters/utils'; +import { ChartCustomizationItem } from '../nativeFilters/ChartCustomization/types'; +import { RootState } from '../../types'; +import { isChartWithoutGroupBy } from '../../util/charts/chartTypeLimitations'; + +export interface GroupByBadgeProps { + chartId: number; +} + +const StyledTag = styled(Tag)` + ${({ theme }) => ` + display: flex; + align-items: center; + cursor: pointer; + margin-left: ${theme.sizeUnit * 2}px; + margin-right: ${theme.sizeUnit}px; + padding: ${theme.sizeUnit * 0.5}px ${theme.sizeUnit}px; + background: ${theme.colorBgContainer}; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.borderRadius}px; + height: auto; + min-height: 20px; + + .anticon { + vertical-align: middle; + color: ${theme.colorTextSecondary}; + margin-right: ${theme.sizeUnit * 0.5}px; + font-size: 12px; + &:hover { + color: ${theme.colorText}; + } + } + + &:hover { + background: ${theme.colorBgTextHover}; + } + + &:focus-visible { + outline: 2px solid ${theme.colorPrimary}; + } + `} +`; + +const StyledBadge = styled(Badge)` + ${({ theme }) => ` + margin-left: ${theme.sizeUnit * 0.5}px; + &>sup.ant-badge-count { + padding: 0 ${theme.sizeUnit * 0.5}px; + min-width: ${theme.sizeUnit * 3}px; + height: ${theme.sizeUnit * 3}px; + line-height: 1.2; + font-weight: ${theme.fontWeightStrong}; + font-size: ${theme.fontSizeSM - 2}px; + box-shadow: none; + } + `} +`; + +const TooltipContent = styled.div` + ${({ theme }) => ` + min-width: 200px; + max-width: 300px; + overflow-x: hidden; + color: ${theme.colorText}; + font-size: ${theme.fontSizeSM}px; + `} +`; + +const SectionName = styled.span` + ${({ theme }) => ` + font-weight: ${theme.fontWeightStrong}; + font-size: ${theme.fontSizeSM}px; + `} +`; + +const GroupByInfo = styled.div` + ${({ theme }) => ` + margin-top: ${theme.sizeUnit}px; + &:not(:last-child) { + padding-bottom: ${theme.sizeUnit * 3}px; + } + `} +`; + +const GroupByItem = styled.div` + ${({ theme }) => ` + font-size: ${theme.fontSizeSM}px; + margin-bottom: ${theme.sizeUnit}px; + + &:last-child { + margin-bottom: 0; + } + `} +`; + +const GroupByName = styled.span` + ${({ theme }) => ` + padding-right: ${theme.sizeUnit}px; + font-style: italic; + `} +`; + +const GroupByValue = styled.span` + max-width: 100%; + flex-grow: 1; + overflow: auto; +`; + +export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => { + const [tooltipVisible, setTooltipVisible] = useState(false); + const triggerRef = useRef(null); + const theme = useTheme(); + + const chartCustomizationItems = useSelector< + RootState, + ChartCustomizationItem[] + >( + ({ dashboardInfo }) => + dashboardInfo.metadata?.chart_customization_config || [], + ); + + const chartDataset = useSelector(state => { + const chart = state.charts[chartId]; + if (!chart?.latestQueryFormData?.datasource) { + return null; + } + const chartDatasetParts = String( + chart.latestQueryFormData.datasource, + ).split('__'); + return chartDatasetParts[0]; + }); + + const applicableGroupBys = useMemo(() => { + if (!chartDataset) { + return []; + } + + return chartCustomizationItems.filter(item => { + if (item.removed) return false; + + const targetDataset = item.customization?.dataset; + if (!targetDataset) return false; + + const targetDatasetId = String(targetDataset); + const matchesDataset = chartDataset === targetDatasetId; + + const hasColumn = item.customization?.column; + + return matchesDataset && hasColumn; + }); + }, [chartCustomizationItems, chartDataset]); + + const chart = useSelector(state => state.charts[chartId]); + const chartType = chart?.latestQueryFormData?.viz_type; + + const effectiveGroupBys = useMemo(() => { + if (!chartType || applicableGroupBys.length === 0) { + return []; + } + + if (isChartWithoutGroupBy(chartType)) { + return []; + } + + const chartFormData = chart?.latestQueryFormData; + 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; + }); + }, [applicableGroupBys, chartType, chart]); + + const groupByCount = effectiveGroupBys.length; + + if (groupByCount === 0) { + return null; + } + const tooltipContent = ( + +
+ + {t('Chart Customization (%d)', applicableGroupBys.length)} + + + {effectiveGroupBys.map(groupBy => ( + +
+ {groupBy.customization?.name && + groupBy.customization?.column ? ( + <> + {groupBy.customization.name}: + + {getFilterValueForDisplay(groupBy.customization.column)} + + + ) : ( + groupBy.customization?.name || t('None') + )} +
+
+ ))} +
+
+
+ ); + + return ( + + + + + + + ); +}; + +export default memo(GroupByBadge); diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 49745bb5da9..f79529f0803 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -40,6 +40,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 { RootState } from 'src/dashboard/types'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; @@ -299,6 +300,9 @@ const SliceHeader = forwardRef( )} + {!uiConfig.hideChartControls && ( + + )} {!uiConfig.hideChartControls && ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx index d81a6ed0f25..e91a1667b88 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx @@ -267,6 +267,9 @@ const Chart = props => { const chartConfiguration = useSelector( state => state.dashboardInfo.metadata?.chart_configuration, ); + const chartCustomizationItems = useSelector( + state => state.dashboardInfo.metadata?.chart_customization_config || [], + ); const colorScheme = useSelector(state => state.dashboardState.colorScheme); const colorNamespace = useSelector( state => state.dashboardState.colorNamespace, @@ -294,6 +297,7 @@ const Chart = props => { getFormDataWithExtraFilters({ chart, chartConfiguration, + chartCustomizationItems, filters: getAppliedFilterValues(props.id), colorScheme, colorNamespace, @@ -310,6 +314,7 @@ const Chart = props => { [ chart, chartConfiguration, + chartCustomizationItems, props.id, props.extraControls, colorScheme, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx new file mode 100644 index 00000000000..149baf54ae9 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/ChartCustomization/ChartCustomizationForm.tsx @@ -0,0 +1,1456 @@ +/** + * 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, styled, css, useTheme } from '@superset-ui/core'; +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(); + }} + /> + + + + + + +