sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r4024141426


##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,331 @@
+/**
+ * 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 {
+  ComponentType,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+  useEffect,
+} from 'react';
+import {
+  QueryData,
+  QueryFormData,
+  BinaryQueryObjectFilterClause,
+  FeatureFlag,
+  isFeatureEnabled,
+  ensureIsArray,
+} from '@superset-ui/core';
+import { useSelector } from 'react-redux';
+import { css } from '@apache-superset/core/theme';
+import { ChartSource } from 'src/types/ChartSource';
+import type { RootState } from 'src/dashboard/types';
+import { useDrillDownState } from './useDrillDownState';
+import { DrillDownBreadcrumb } from './DrillDownBreadcrumb';
+import type { ChartRendererProps } from '../ChartRenderer';
+
+/**
+ * Hook payload contract: chart plugins call `onDrillDown(filters, label)`
+ * via the chart's hooks bag when the user clicks a data point and a
+ * drill-down hierarchy is configured.
+ */
+export type OnDrillDownHook = (
+  filters: BinaryQueryObjectFilterClause[],
+  label: string,
+) => void;
+
+/**
+ * Build the cross-filter clauses the dashboard data-mask expects from a set of
+ * drill-down filter clauses. Shared by the drill (onDrillDown) and the
+ * breadcrumb navigation (handleResetTo) paths so their cross-filter shape can
+ * never diverge. Special cases mirror the native ECharts cross-filter path: a
+ * temporal bucket click is passed through as a `TEMPORAL_RANGE`, and a null
+ * value becomes `IS NULL` (rather than `IN [null]`, which selects nothing).
+ */
+const toCrossFilterClauses = (filters: BinaryQueryObjectFilterClause[]) =>
+  filters.map(f => {
+    if (f.op === 'TEMPORAL_RANGE') {
+      return { col: f.col, op: 'TEMPORAL_RANGE' as const, val: f.val };
+    }
+    if (f.val == null) {
+      return { col: f.col, op: 'IS NULL' as const };
+    }
+    return {
+      col: f.col,
+      op: 'IN' as const,
+      val: [f.val] as (string | number | boolean)[],
+    };
+  });
+
+interface DrillDownHostProps extends ChartRendererProps {
+  /** The wrapped renderer component */
+  ChartRendererComponent: ComponentType<
+    ChartRendererProps & { onDrillDown?: OnDrillDownHook }
+  >;
+}
+
+/**
+ * Wraps `<ChartRenderer>` with drill-down behavior. When the chart's
+ * form_data declares a `drilldown_hierarchy`, this host:
+ *
+ *  1. Tracks how deep the user has drilled (a stack of levels)
+ *  2. Computes "effective" form_data for the current level (swapping the
+ *     grouping dimension — groupby or x_axis — and adding accumulated filters)
+ *  3. Re-fetches chart data for that level
+ *  4. Renders a breadcrumb above the chart for navigating back up
+ *
+ * If the chart has no hierarchy, this is a thin pass-through.
+ */
+export function DrillDownHost({
+  ChartRendererComponent,
+  ...rendererProps
+}: DrillDownHostProps) {
+  const { formData, queriesResponse } = rendererProps;
+
+  // Live cross-filter selection this chart has emitted into the dashboard data
+  // mask. A drilled chart writes its path here; when the mask is cleared
+  // (dashboard teardown, or the user removes the cross-filter from the filter
+  // bar) this goes empty. The hook uses it to discard persisted drill state
+  // that outlived its data mask instead of replaying it on remount.
+  const crossFilterValue = useSelector<RootState, unknown>(
+    state => state.dataMask?.[rendererProps.chartId]?.filterState?.value,
+  );
+  const crossFilterCleared =
+    !!rendererProps.emitCrossFilters &&
+    ensureIsArray(crossFilterValue).length === 0;
+
+  const {
+    isDrilling,
+    drillStack,
+    selectedLeaf,
+    hierarchy,
+    effectiveFormData,
+    effectiveQueriesResponse,
+    isLoading,
+    error,
+    hasHierarchy,
+    drillDown,
+    resetTo,
+  } = useDrillDownState({
+    chartId: rendererProps.chartId,
+    formData,
+    baseQueriesResponse: queriesResponse,
+    crossFilterCleared,
+  });
+
+  // Drill-down is a dashboard interaction gated behind the DRILL_DOWN feature
+  // flag. In Explore the control panel and the rendered chart would disagree
+  // (and clicks there mean something else), so only enable it when rendered
+  // inside a dashboard.
+  const drillEnabled =
+    isFeatureEnabled(FeatureFlag.DrillDown) &&
+    hasHierarchy &&
+    rendererProps.source === ChartSource.Dashboard;
+
+  const onDrillDown = useMemo<OnDrillDownHook | undefined>(() => {
+    if (!drillEnabled) {
+      return undefined;
+    }
+    return (filters, label) => {
+      // Emit a cross-filter for the FULL drill path — every level reached so
+      // far plus this click — so other dashboard charts are scoped to exactly
+      // what the drilled chart shows, not just the deepest clicked column.
+      const pathFilters = [
+        ...drillStack.flatMap(level => level.filters),
+        ...filters,
+      ];
+      const pathLabels = [...drillStack.map(level => level.label), label];
+      drillDown(filters, label);
+      if (
+        rendererProps.emitCrossFilters &&
+        rendererProps.actions?.updateDataMask
+      ) {
+        rendererProps.actions.updateDataMask(rendererProps.chartId, {
+          extraFormData: {
+            filters: toCrossFilterClauses(pathFilters),
+          },
+          filterState: {
+            value: pathLabels,
+            selectedValues: pathLabels,
+          },
+        });
+      }
+    };
+  }, [
+    drillEnabled,
+    drillDown,
+    drillStack,
+    rendererProps.emitCrossFilters,
+    rendererProps.actions,
+    rendererProps.chartId,
+  ]);
+
+  const overlayProps = useMemo<Partial<ChartRendererProps>>(() => {
+    if (!isDrilling) {
+      // At the base level, render the chart unchanged.
+      return {};
+    }
+    // A failed drill query leaves effectiveQueriesResponse null. Rather than
+    // pin the chart body to a perpetual loading spinner, fall back to the base
+    // chart (recoverable): the breadcrumb and error banner above convey the
+    // failure and let the user navigate back or retry.
+    if (error != null && !isLoading) {

Review Comment:
   After a failed drill, this renders the base chart but keeps the existing 
drill stack and active click handler. Clicking the recovered chart then appends 
a base-level value as the next level, producing contradictory filters such as 
`country=USA` plus `country=Canada`; could the error path reset or retry the 
stack, or disable drilling until recovery?



##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,542 @@
+/**
+ * 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 {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { t } from '@apache-superset/core/translation';
+import {
+  BinaryQueryObjectFilterClause,
+  ensureIsArray,
+  getClientErrorObject,
+  QueryData,
+  QueryFormData,
+} from '@superset-ui/core';
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
+import { requestChartDataResolved } from 'src/components/Chart/chartAction';
+import { DrillDownLevel } from './types';
+
+/**
+ * The form-data field name that stores the ordered list of drill columns.
+ * The chart starts at hierarchy[0] and advances one level per click.
+ */
+const HIERARCHY_FIELD = 'drilldown_hierarchy';
+const HIERARCHY_FIELD_CAMEL = 'drilldownHierarchy';
+
+/**
+ * Default form-data field that holds the chart's grouping dimension.
+ * Most echarts plugins use 'groupby'; Sunburst uses 'columns'. The click
+ * handler can override this on a per-event basis.
+ */
+const DEFAULT_GROUPBY_FIELD = 'groupby';
+const DEFAULT_ADHOC_FILTERS_FIELD = 'adhoc_filters';
+
+/**
+ * Drill navigation is kept in a module-level store keyed by chart id so it
+ * survives incidental remounts of the chart component. On a dashboard, an
+ * unrelated filter change (e.g. removing another chart's cross-filter) can
+ * cause the grid to re-render and remount the chart, which would otherwise
+ * reset the local React state and make the breadcrumb vanish mid-drill,
+ * stranding the user with no way to navigate back up. The store is process
+ * memory only — a full page reload still starts fresh.
+ */
+interface StoredDrillState {
+  drillStack: DrillDownLevel[];
+  selectedLeaf?: string;
+  /** Filters for the value selected at the deepest level, if any. */
+  selectedLeafFilters?: BinaryQueryObjectFilterClause[];
+  /**
+   * Identity of the chart configuration (and dashboard slot) this state
+   * belongs to. A remount that restores from the store compares this against
+   * the current config so a stale record from a different configuration is
+   * discarded rather than replayed.
+   */
+  configKey: string;
+}
+const drillStateStore = new Map<string | number, StoredDrillState>();
+
+/**
+ * Clear persisted drill state. Without arguments clears everything (used by
+ * tests to isolate cases); with a chart id clears just that chart.
+ */
+export function clearDrillDownState(chartKey?: string | number): void {
+  if (chartKey === undefined) {
+    drillStateStore.clear();
+  } else {
+    drillStateStore.delete(chartKey);
+  }
+}
+
+interface UseDrillDownStateArgs {
+  /** Unique chart instance id (dashboard grid assigns one per slot). */
+  chartId: string | number;
+  formData: QueryFormData;
+  /** Original chart data, shown when the drill stack is empty */
+  baseQueriesResponse?: QueryData[] | null;
+  /**
+   * True when the chart's owning cross-filter data mask has been cleared
+   * (dashboard teardown, or the user removed this chart's cross-filter from 
the
+   * filter bar). Persisted drill state mirrors that data mask, so when it is
+   * gone the stored stack is orphaned: on mount it is discarded instead of
+   * replayed (which would re-fire a drilled query while linked charts sit at
+   * root). Defaults to false so non-dashboard callers keep plain persistence.
+   */
+  crossFilterCleared?: boolean;
+}
+
+interface UseDrillDownStateResult {
+  /** True if the user has drilled at least one level deep */
+  isDrilling: boolean;
+  /** The breadcrumb path showing where the user is in the hierarchy */
+  drillStack: DrillDownLevel[];
+  /** Value selected at the deepest level */
+  selectedLeaf?: string;
+  /** The computed hierarchy of column names */
+  hierarchy: string[];
+  /** form_data adjusted for the current drill level */
+  effectiveFormData: QueryFormData;
+  /** Chart data for the current drill level (or base data when not drilling) 
*/
+  effectiveQueriesResponse: QueryData[] | null | undefined;
+  /** True while the next-level data is being fetched */
+  isLoading: boolean;
+  /** Error message if the drill query failed */
+  error?: string;
+  /** Whether the chart has a configured drill-down hierarchy */
+  hasHierarchy: boolean;
+  /**
+   * Push a new level onto the drill stack. Called from the chart's click
+   * handler with the filters that identify the clicked data point.
+   */
+  drillDown: (filters: BinaryQueryObjectFilterClause[], label: string) => void;
+  /** Truncate the drill stack to the given depth (0 = back to start) */
+  resetTo: (depth: number) => void;
+}
+
+/**
+ * Hook that manages a chart's drill-down state. Owns the drill stack,
+ * computes the effective form_data for the current level, fetches the
+ * data for that level, and exposes navigation helpers (drillDown / resetTo).
+ *
+ * The hook never mutates the upstream Redux store: closing or refreshing
+ * the dashboard wipes the drill state and restores the original chart.
+ */
+export function useDrillDownState({
+  chartId,
+  formData,
+  baseQueriesResponse,
+  crossFilterCleared,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+  const chartKey = chartId;
+
+  // Identity of the current drill configuration (and dashboard slot). The 
drill
+  // stack is anchored to the primary dimension (x_axis or groupby) and the
+  // hierarchy list, so this key changes whenever the drill would target a
+  // different set of columns. It is stored alongside persisted state and
+  // compared on restore.
+  const configFd = formData as Record<string, unknown>;
+  const configKey = JSON.stringify([
+    chartId,
+    formData.viz_type,
+    configFd.x_axis ?? configFd.xAxis,
+    configFd[HIERARCHY_FIELD] ?? configFd[HIERARCHY_FIELD_CAMEL],
+    configFd[DEFAULT_GROUPBY_FIELD],
+  ]);
+
+  // Restore persisted state only when it still belongs to this chart: the
+  // stored config identity must match, and the cross-filter data mask that
+  // backed the drill must not have been cleared while the chart was unmounted.
+  // Otherwise the record is orphaned and replaying it would re-fire a drilled
+  // query while linked charts are back at root.
+  const storedState =
+    chartKey != null ? drillStateStore.get(chartKey) : undefined;
+  const restoredState =
+    storedState && storedState.configKey === configKey && !crossFilterCleared
+      ? storedState
+      : undefined;
+
+  // Drill state intentionally persists in drillStateStore across unmounts
+  // (dashboard virtualization scroll-out, tab switches, filter re-layouts) so
+  // it stays in sync with the cross-filter the drill emits into Redux. 
Evicting
+  // it on unmount previously left the emitted cross-filter orphaned — the 
drill
+  // appeared to reset while the filter lingered. The store is cleared on chart
+  // reconfigure (the layout effect below), on an orphaned restore (below), and
+  // via clearDrillDownState.
+
+  const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+    () => restoredState?.drillStack ?? [],
+  );
+  const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(
+    () => restoredState?.selectedLeaf,
+  );
+  // Filters for the value picked at the deepest level. Applied to the drilled
+  // chart's own query so it narrows to the selected leaf (a single bar),
+  // independent of the dashboard's cross-filter scope config. Without this the
+  // drilled chart keeps showing the full leaf distribution and only charts 
that
+  // happen to include themselves in their cross-filter scope look "filtered".
+  const [selectedLeafFilters, setSelectedLeafFilters] = useState<
+    BinaryQueryObjectFilterClause[] | undefined
+  >(() => restoredState?.selectedLeafFilters);
+  const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | undefined>();
+
+  // Evict a persisted record that exists but was not restored (orphaned by a
+  // config change or a cleared data mask) so it cannot leak to a later 
remount.
+  const evictedOrphanRef = useRef(false);
+  useLayoutEffect(() => {
+    if (evictedOrphanRef.current) {
+      return;
+    }
+    evictedOrphanRef.current = true;
+    if (chartKey != null && storedState && !restoredState) {
+      drillStateStore.delete(chartKey);
+    }
+    // Run once on mount; storedState/restoredState reflect the initial state.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Persist drill navigation synchronously so it survives remounts (see
+  // drillStateStore) without racing. Writing on a deferred effect would let a
+  // remount triggered by the same interaction (e.g. clearing a cross-filter)
+  // restore stale state before the effect runs, so the mutators below write
+  // through this helper immediately instead.
+  const persist = useCallback(
+    (
+      stack: DrillDownLevel[],
+      leaf: string | undefined,
+      leafFilters: BinaryQueryObjectFilterClause[] | undefined,
+    ) => {
+      if (chartKey == null) {
+        return;
+      }
+      if (stack.length === 0 && !leaf) {
+        drillStateStore.delete(chartKey);
+      } else {
+        drillStateStore.set(chartKey, {
+          drillStack: stack,
+          selectedLeaf: leaf,
+          selectedLeafFilters: leafFilters,
+          configKey,
+        });
+      }
+    },
+    [chartKey, configKey],
+  );
+
+  // Reset when the drill configuration changes, not just chart id or viz type.
+  // The drill stack is anchored to the primary dimension (x_axis or groupby)
+  // and the hierarchy list, so editing either — even without a viz-type change
+  // — must clear stale state whose next dimension no longer exists. A ref 
guard
+  // ensures the initial mount (which restores persisted state) does not wipe
+  // it, and that incidental re-renders from filter changes don't either.
+  // useLayoutEffect runs synchronously before paint so the stale drill state
+  // is cleared without a visible flash when the chart is reconfigured.
+  const prevConfigKeyRef = useRef(configKey);
+  useLayoutEffect(() => {
+    if (prevConfigKeyRef.current === configKey) {
+      return;
+    }
+    prevConfigKeyRef.current = configKey;
+    if (chartKey != null) {
+      drillStateStore.delete(chartKey);
+    }
+    setDrillStack([]);
+    setSelectedLeaf(undefined);
+    setSelectedLeafFilters(undefined);
+    setDrillData(null);
+    setError(undefined);
+  }, [configKey, chartKey]);
+
+  const hierarchy = useMemo<string[]>(() => {
+    const fd = formData as Record<string, unknown>;
+    const xAxis = fd.x_axis ?? fd.xAxis;
+
+    // Primary source: the dedicated `drilldown_hierarchy` control. The chart's
+    // own primary dimension (x_axis for axis charts, the first groupby column
+    // for groupby charts) is the top level and is prepended automatically when
+    // the author lists only the deeper levels.
+    const drillLevels = ensureIsArray(
+      fd[HIERARCHY_FIELD] ?? fd[HIERARCHY_FIELD_CAMEL],
+    ) as string[];
+    if (drillLevels.length > 0) {
+      // The primary dimension is always the initial (index 0) level, even if
+      // the author listed it later in the control; normalize it to the front
+      // (deduped) so the first drill advances off the primary dimension.
+      const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+      if (xAxisStr) {
+        return [xAxisStr, ...drillLevels.filter(col => col !== xAxisStr)];
+      }
+      const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
+        col => typeof col === 'string',
+      ) as string | undefined;
+      if (firstGroupby) {
+        return [
+          firstGroupby,
+          ...drillLevels.filter(col => col !== firstGroupby),
+        ];
+      }
+      return drillLevels;
+    }
+
+    return [];
+  }, [formData]);
+
+  // A hierarchy needs at least two levels to be drillable; a single column
+  // (e.g. the author listed only the chart's own dimension) is a no-op that
+  // would otherwise hijack the normal cross-filter click without ever
+  // advancing.
+  const hasHierarchy = hierarchy.length >= 2;
+  const currentDepth = drillStack.length;
+
+  const effectiveFormData = useMemo<QueryFormData>(() => {
+    if (currentDepth === 0) {
+      return formData;
+    }
+    const nextColumn = hierarchy[currentDepth];
+
+    // Merge accumulated filters from every level into adhoc_filters.
+    const accumulatedFilters = drillStack.flatMap(level => level.filters);
+    const baseAdhoc = ensureIsArray(
+      (formData as Record<string, unknown>)[DEFAULT_ADHOC_FILTERS_FIELD],
+    );
+
+    const fdRecord = formData as Record<string, unknown>;
+
+    // Swap the field the hierarchy is anchored to. When the chart has an
+    // x-axis, the hierarchy is x-axis driven (the groupby, if any, is only a
+    // series breakdown and must be preserved), so swap x_axis. Only groupby-
+    // based charts (Pie/Funnel/…, no x_axis) swap the groupby.
+    const xAxisIsSet =
+      typeof fdRecord.x_axis === 'string' || typeof fdRecord.xAxis === 
'string';
+    const groupbyValue = fdRecord[DEFAULT_GROUPBY_FIELD];
+
+    const updated = { ...formData } as Record<string, unknown>;
+
+    if (xAxisIsSet) {
+      // Axis charts (Bar/Line/Area/…): advance the x-axis column.
+      if (typeof fdRecord.x_axis === 'string') {
+        updated.x_axis = nextColumn;
+      }
+      if (typeof fdRecord.xAxis === 'string') {
+        updated.xAxis = nextColumn;
+      }
+    } else {
+      // Groupby-based charts: advance the grouping dimension.
+      updated[DEFAULT_GROUPBY_FIELD] = Array.isArray(groupbyValue)
+        ? [nextColumn]
+        : nextColumn;
+    }
+
+    // At the deepest level a picked value narrows the chart to that single
+    // leaf (matching the breadcrumb selection), rather than showing the full
+    // leaf distribution. Only apply the leaf filters while a leaf is actually
+    // selected so that navigating back (resetTo) or drilling deeper never
+    // leaves stale leaf filters in effectiveFormData. Test presence, not
+    // truthiness, so an empty-string category still counts as a selection.
+    const leafFilters = selectedLeaf != null ? (selectedLeafFilters ?? []) : 
[];
+
+    updated[DEFAULT_ADHOC_FILTERS_FIELD] = [
+      ...baseAdhoc,
+      ...accumulatedFilters.map(f => simpleFilterToAdhoc(f)),
+      ...leafFilters.map(f => simpleFilterToAdhoc(f)),
+    ];
+
+    return updated as QueryFormData;
+  }, [
+    formData,
+    drillStack,
+    currentDepth,
+    hierarchy,
+    selectedLeaf,
+    selectedLeafFilters,
+  ]);
+
+  // Keep the latest effective form-data reachable from the fetch effect
+  // without listing the object itself as a dependency (its identity churns on
+  // every unrelated dashboard re-render).
+  const effectiveFormDataRef = useRef(effectiveFormData);
+  effectiveFormDataRef.current = effectiveFormData;
+
+  // Re-run the fetch only when the *content* of the drill query changes, not
+  // when an unrelated re-render (e.g. a cross-filter update elsewhere on the
+  // dashboard) hands us a new formData object with identical values. Without
+  // this guard those identity-only changes re-run the effect and cancel the
+  // in-flight request before it can clear the loading flag, leaving the chart
+  // spinning until the 60s query timeout.
+  // Only serialize while actually drilling — at depth 0 effectiveFormData is
+  // just the base formData and the fetch effect early-returns, so there is no
+  // need to stringify it on every render of every chart in the app.
+  const effectiveFormDataKey = useMemo(
+    () => (currentDepth > 0 ? JSON.stringify(effectiveFormData) : ''),
+    [currentDepth, effectiveFormData],
+  );
+
+  // Fetch data whenever the user drills (stack changes and is non-empty).
+  useEffect(() => {
+    if (currentDepth === 0) {
+      setDrillData(null);
+      setError(undefined);
+      return undefined;
+    }
+
+    const activeFormData = effectiveFormDataRef.current;
+    const controller = new AbortController();
+    let cancelled = false;
+    setIsLoading(true);
+    setError(undefined);
+
+    const extractMessage = async (err: unknown): Promise<string> => {
+      let message = (err as { message?: string })?.message;
+      try {
+        const clientError = await getClientErrorObject(
+          err as Parameters<typeof getClientErrorObject>[0],
+        );
+        message =
+          clientError?.message ||
+          clientError?.error ||
+          (clientError?.errors && clientError.errors[0]?.message) ||
+          message;
+      } catch {
+        // fall back to err.message
+      }
+      return message || t('Failed to load chart data');
+    };
+
+    // The backend can intermittently fail under the burst of concurrent chart
+    // queries a drill click triggers (it also emits a cross-filter, which
+    // re-queries every dashboard chart at once). These failures are transient,
+    // so retry a few times with a short backoff before surfacing the error.
+    const MAX_ATTEMPTS = 3;
+    const RETRY_DELAY_MS = 400;
+
+    const runWithRetry = async () => {
+      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
+        try {
+          // eslint-disable-next-line no-await-in-loop
+          const queriesResponse = await requestChartDataResolved(

Review Comment:
   The `AbortSignal` here only reaches the async-task wait; the chart-data POST 
reads `signal`, `timeout`, and `async_mode_override` from `requestParams`, 
which this call omits. Superseded synchronous drills therefore keep running, 
hung queries lack the normal timeout, and dashboard async overrides are 
ignored—could this pass the same request params as `exploreJSON`?



##########
docs/docs/using-superset/exploring-data.mdx:
##########
@@ -351,6 +351,26 @@ to be drawn as zero across the entire day rather than only 
around the hours that
 Lastly, save your chart as Tutorial Resample and add it to the Tutorial 
Dashboard. Go to the
 tutorial dashboard to see the four charts side by side and compare the 
different outputs.
 
+### Drilling into data
+
+Superset offers three distinct "drill" interactions. They are independent, 
each gated by its own
+feature flag, and can be enabled in any combination:
+
+- **Drill to detail** (`DRILL_TO_DETAIL`): right-click a data point and choose 
**Drill to detail**
+  to open a modal listing the individual rows that make up that point. It 
answers "what records are
+  behind this value?" without changing the chart.
+- **Drill by** (`DRILL_BY`): right-click a data point and choose **Drill by** 
to open a modal that
+  re-groups the chart by a different dimension, scoped to the clicked value. 
It answers "how does
+  this value break down by another column?" and leaves the original chart 
untouched.
+- **Drill-down hierarchy** (`DRILL_DOWN`, off by default): configure an 
ordered **Drill-down
+  hierarchy** in the chart's control panel, then left-click a data point on a 
dashboard chart to
+  descend to the next level of that hierarchy *in place*. A breadcrumb above 
the chart lets you
+  step back up, and each level emits a cross-filter for the accumulated path 
so the rest of the

Review Comment:
   This promises that every drill updates the rest of the dashboard, but 
`DrillDownHost` emits the data mask only when the dashboard's Cross filters 
setting is enabled. Could the docs state that prerequisite so users with cross 
filters disabled do not expect linked charts to follow?



##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx:
##########
@@ -314,6 +314,7 @@ function createAxisControl(axis: 'x' | 'y'): 
ControlSetRow[] {
 const config: ControlPanelConfig = {
   controlPanelSections: [
     sections.echartsTimeSeriesQueryWithXAxisSort,
+    sections.drilldownHierarchySection,

Review Comment:
   Agreed—the section itself remains visible while its only control is hidden, 
so the default `DRILL_DOWN`-off configuration leaves an empty collapsible 
panel. Could the feature flag gate the section visibility rather than only the 
control?



##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,331 @@
+/**
+ * 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 {
+  ComponentType,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+  useEffect,
+} from 'react';
+import {
+  QueryData,
+  QueryFormData,
+  BinaryQueryObjectFilterClause,
+  FeatureFlag,
+  isFeatureEnabled,
+  ensureIsArray,
+} from '@superset-ui/core';
+import { useSelector } from 'react-redux';
+import { css } from '@apache-superset/core/theme';
+import { ChartSource } from 'src/types/ChartSource';
+import type { RootState } from 'src/dashboard/types';
+import { useDrillDownState } from './useDrillDownState';
+import { DrillDownBreadcrumb } from './DrillDownBreadcrumb';
+import type { ChartRendererProps } from '../ChartRenderer';
+
+/**
+ * Hook payload contract: chart plugins call `onDrillDown(filters, label)`
+ * via the chart's hooks bag when the user clicks a data point and a
+ * drill-down hierarchy is configured.
+ */
+export type OnDrillDownHook = (
+  filters: BinaryQueryObjectFilterClause[],
+  label: string,
+) => void;
+
+/**
+ * Build the cross-filter clauses the dashboard data-mask expects from a set of
+ * drill-down filter clauses. Shared by the drill (onDrillDown) and the
+ * breadcrumb navigation (handleResetTo) paths so their cross-filter shape can
+ * never diverge. Special cases mirror the native ECharts cross-filter path: a
+ * temporal bucket click is passed through as a `TEMPORAL_RANGE`, and a null
+ * value becomes `IS NULL` (rather than `IN [null]`, which selects nothing).
+ */
+const toCrossFilterClauses = (filters: BinaryQueryObjectFilterClause[]) =>
+  filters.map(f => {
+    if (f.op === 'TEMPORAL_RANGE') {
+      return { col: f.col, op: 'TEMPORAL_RANGE' as const, val: f.val };
+    }
+    if (f.val == null) {
+      return { col: f.col, op: 'IS NULL' as const };
+    }
+    return {
+      col: f.col,
+      op: 'IN' as const,
+      val: [f.val] as (string | number | boolean)[],
+    };
+  });
+
+interface DrillDownHostProps extends ChartRendererProps {
+  /** The wrapped renderer component */
+  ChartRendererComponent: ComponentType<
+    ChartRendererProps & { onDrillDown?: OnDrillDownHook }
+  >;
+}
+
+/**
+ * Wraps `<ChartRenderer>` with drill-down behavior. When the chart's
+ * form_data declares a `drilldown_hierarchy`, this host:
+ *
+ *  1. Tracks how deep the user has drilled (a stack of levels)
+ *  2. Computes "effective" form_data for the current level (swapping the
+ *     grouping dimension — groupby or x_axis — and adding accumulated filters)
+ *  3. Re-fetches chart data for that level
+ *  4. Renders a breadcrumb above the chart for navigating back up
+ *
+ * If the chart has no hierarchy, this is a thin pass-through.
+ */
+export function DrillDownHost({
+  ChartRendererComponent,
+  ...rendererProps
+}: DrillDownHostProps) {
+  const { formData, queriesResponse } = rendererProps;
+
+  // Live cross-filter selection this chart has emitted into the dashboard data
+  // mask. A drilled chart writes its path here; when the mask is cleared
+  // (dashboard teardown, or the user removes the cross-filter from the filter
+  // bar) this goes empty. The hook uses it to discard persisted drill state
+  // that outlived its data mask instead of replaying it on remount.
+  const crossFilterValue = useSelector<RootState, unknown>(
+    state => state.dataMask?.[rendererProps.chartId]?.filterState?.value,
+  );
+  const crossFilterCleared =
+    !!rendererProps.emitCrossFilters &&
+    ensureIsArray(crossFilterValue).length === 0;
+
+  const {
+    isDrilling,
+    drillStack,
+    selectedLeaf,
+    hierarchy,
+    effectiveFormData,
+    effectiveQueriesResponse,
+    isLoading,
+    error,
+    hasHierarchy,
+    drillDown,
+    resetTo,
+  } = useDrillDownState({
+    chartId: rendererProps.chartId,
+    formData,
+    baseQueriesResponse: queriesResponse,
+    crossFilterCleared,
+  });
+
+  // Drill-down is a dashboard interaction gated behind the DRILL_DOWN feature
+  // flag. In Explore the control panel and the rendered chart would disagree
+  // (and clicks there mean something else), so only enable it when rendered
+  // inside a dashboard.
+  const drillEnabled =
+    isFeatureEnabled(FeatureFlag.DrillDown) &&
+    hasHierarchy &&
+    rendererProps.source === ChartSource.Dashboard;
+
+  const onDrillDown = useMemo<OnDrillDownHook | undefined>(() => {
+    if (!drillEnabled) {
+      return undefined;
+    }
+    return (filters, label) => {
+      // Emit a cross-filter for the FULL drill path — every level reached so
+      // far plus this click — so other dashboard charts are scoped to exactly
+      // what the drilled chart shows, not just the deepest clicked column.
+      const pathFilters = [
+        ...drillStack.flatMap(level => level.filters),
+        ...filters,
+      ];
+      const pathLabels = [...drillStack.map(level => level.label), label];
+      drillDown(filters, label);
+      if (
+        rendererProps.emitCrossFilters &&
+        rendererProps.actions?.updateDataMask
+      ) {
+        rendererProps.actions.updateDataMask(rendererProps.chartId, {
+          extraFormData: {
+            filters: toCrossFilterClauses(pathFilters),
+          },
+          filterState: {
+            value: pathLabels,
+            selectedValues: pathLabels,
+          },
+        });
+      }
+    };
+  }, [
+    drillEnabled,
+    drillDown,
+    drillStack,
+    rendererProps.emitCrossFilters,
+    rendererProps.actions,
+    rendererProps.chartId,
+  ]);
+
+  const overlayProps = useMemo<Partial<ChartRendererProps>>(() => {
+    if (!isDrilling) {
+      // At the base level, render the chart unchanged.
+      return {};
+    }
+    // A failed drill query leaves effectiveQueriesResponse null. Rather than
+    // pin the chart body to a perpetual loading spinner, fall back to the base
+    // chart (recoverable): the breadcrumb and error banner above convey the
+    // failure and let the user navigate back or retry.
+    if (error != null && !isLoading) {
+      return {};
+    }
+    return {
+      formData: effectiveFormData as QueryFormData,

Review Comment:
   These drilled form/data props bypass the dashboard chart's Redux slice, 
while header actions such as export and view-query still read the base 
`formData` from Redux. A user can therefore see a drilled chart but export or 
inspect the undrilled query; could the drilled state be propagated to those 
actions, or the mismatched actions be disabled while drilling?



##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,542 @@
+/**
+ * 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 {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { t } from '@apache-superset/core/translation';
+import {
+  BinaryQueryObjectFilterClause,
+  ensureIsArray,
+  getClientErrorObject,
+  QueryData,
+  QueryFormData,
+} from '@superset-ui/core';
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
+import { requestChartDataResolved } from 'src/components/Chart/chartAction';
+import { DrillDownLevel } from './types';
+
+/**
+ * The form-data field name that stores the ordered list of drill columns.
+ * The chart starts at hierarchy[0] and advances one level per click.
+ */
+const HIERARCHY_FIELD = 'drilldown_hierarchy';
+const HIERARCHY_FIELD_CAMEL = 'drilldownHierarchy';
+
+/**
+ * Default form-data field that holds the chart's grouping dimension.
+ * Most echarts plugins use 'groupby'; Sunburst uses 'columns'. The click
+ * handler can override this on a per-event basis.
+ */
+const DEFAULT_GROUPBY_FIELD = 'groupby';
+const DEFAULT_ADHOC_FILTERS_FIELD = 'adhoc_filters';
+
+/**
+ * Drill navigation is kept in a module-level store keyed by chart id so it
+ * survives incidental remounts of the chart component. On a dashboard, an
+ * unrelated filter change (e.g. removing another chart's cross-filter) can
+ * cause the grid to re-render and remount the chart, which would otherwise
+ * reset the local React state and make the breadcrumb vanish mid-drill,
+ * stranding the user with no way to navigate back up. The store is process
+ * memory only — a full page reload still starts fresh.
+ */
+interface StoredDrillState {
+  drillStack: DrillDownLevel[];
+  selectedLeaf?: string;
+  /** Filters for the value selected at the deepest level, if any. */
+  selectedLeafFilters?: BinaryQueryObjectFilterClause[];
+  /**
+   * Identity of the chart configuration (and dashboard slot) this state
+   * belongs to. A remount that restores from the store compares this against
+   * the current config so a stale record from a different configuration is
+   * discarded rather than replayed.
+   */
+  configKey: string;
+}
+const drillStateStore = new Map<string | number, StoredDrillState>();
+
+/**
+ * Clear persisted drill state. Without arguments clears everything (used by
+ * tests to isolate cases); with a chart id clears just that chart.
+ */
+export function clearDrillDownState(chartKey?: string | number): void {
+  if (chartKey === undefined) {
+    drillStateStore.clear();
+  } else {
+    drillStateStore.delete(chartKey);
+  }
+}
+
+interface UseDrillDownStateArgs {
+  /** Unique chart instance id (dashboard grid assigns one per slot). */
+  chartId: string | number;
+  formData: QueryFormData;
+  /** Original chart data, shown when the drill stack is empty */
+  baseQueriesResponse?: QueryData[] | null;
+  /**
+   * True when the chart's owning cross-filter data mask has been cleared
+   * (dashboard teardown, or the user removed this chart's cross-filter from 
the
+   * filter bar). Persisted drill state mirrors that data mask, so when it is
+   * gone the stored stack is orphaned: on mount it is discarded instead of
+   * replayed (which would re-fire a drilled query while linked charts sit at
+   * root). Defaults to false so non-dashboard callers keep plain persistence.
+   */
+  crossFilterCleared?: boolean;
+}
+
+interface UseDrillDownStateResult {
+  /** True if the user has drilled at least one level deep */
+  isDrilling: boolean;
+  /** The breadcrumb path showing where the user is in the hierarchy */
+  drillStack: DrillDownLevel[];
+  /** Value selected at the deepest level */
+  selectedLeaf?: string;
+  /** The computed hierarchy of column names */
+  hierarchy: string[];
+  /** form_data adjusted for the current drill level */
+  effectiveFormData: QueryFormData;
+  /** Chart data for the current drill level (or base data when not drilling) 
*/
+  effectiveQueriesResponse: QueryData[] | null | undefined;
+  /** True while the next-level data is being fetched */
+  isLoading: boolean;
+  /** Error message if the drill query failed */
+  error?: string;
+  /** Whether the chart has a configured drill-down hierarchy */
+  hasHierarchy: boolean;
+  /**
+   * Push a new level onto the drill stack. Called from the chart's click
+   * handler with the filters that identify the clicked data point.
+   */
+  drillDown: (filters: BinaryQueryObjectFilterClause[], label: string) => void;
+  /** Truncate the drill stack to the given depth (0 = back to start) */
+  resetTo: (depth: number) => void;
+}
+
+/**
+ * Hook that manages a chart's drill-down state. Owns the drill stack,
+ * computes the effective form_data for the current level, fetches the
+ * data for that level, and exposes navigation helpers (drillDown / resetTo).
+ *
+ * The hook never mutates the upstream Redux store: closing or refreshing
+ * the dashboard wipes the drill state and restores the original chart.
+ */
+export function useDrillDownState({
+  chartId,
+  formData,
+  baseQueriesResponse,
+  crossFilterCleared,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+  const chartKey = chartId;
+
+  // Identity of the current drill configuration (and dashboard slot). The 
drill
+  // stack is anchored to the primary dimension (x_axis or groupby) and the
+  // hierarchy list, so this key changes whenever the drill would target a
+  // different set of columns. It is stored alongside persisted state and
+  // compared on restore.
+  const configFd = formData as Record<string, unknown>;
+  const configKey = JSON.stringify([
+    chartId,
+    formData.viz_type,
+    configFd.x_axis ?? configFd.xAxis,
+    configFd[HIERARCHY_FIELD] ?? configFd[HIERARCHY_FIELD_CAMEL],
+    configFd[DEFAULT_GROUPBY_FIELD],
+  ]);
+
+  // Restore persisted state only when it still belongs to this chart: the
+  // stored config identity must match, and the cross-filter data mask that
+  // backed the drill must not have been cleared while the chart was unmounted.
+  // Otherwise the record is orphaned and replaying it would re-fire a drilled
+  // query while linked charts are back at root.
+  const storedState =
+    chartKey != null ? drillStateStore.get(chartKey) : undefined;
+  const restoredState =
+    storedState && storedState.configKey === configKey && !crossFilterCleared
+      ? storedState
+      : undefined;
+
+  // Drill state intentionally persists in drillStateStore across unmounts
+  // (dashboard virtualization scroll-out, tab switches, filter re-layouts) so
+  // it stays in sync with the cross-filter the drill emits into Redux. 
Evicting
+  // it on unmount previously left the emitted cross-filter orphaned — the 
drill
+  // appeared to reset while the filter lingered. The store is cleared on chart
+  // reconfigure (the layout effect below), on an orphaned restore (below), and
+  // via clearDrillDownState.
+
+  const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+    () => restoredState?.drillStack ?? [],
+  );
+  const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(
+    () => restoredState?.selectedLeaf,
+  );
+  // Filters for the value picked at the deepest level. Applied to the drilled
+  // chart's own query so it narrows to the selected leaf (a single bar),
+  // independent of the dashboard's cross-filter scope config. Without this the
+  // drilled chart keeps showing the full leaf distribution and only charts 
that
+  // happen to include themselves in their cross-filter scope look "filtered".
+  const [selectedLeafFilters, setSelectedLeafFilters] = useState<
+    BinaryQueryObjectFilterClause[] | undefined
+  >(() => restoredState?.selectedLeafFilters);
+  const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | undefined>();
+
+  // Evict a persisted record that exists but was not restored (orphaned by a
+  // config change or a cleared data mask) so it cannot leak to a later 
remount.
+  const evictedOrphanRef = useRef(false);
+  useLayoutEffect(() => {
+    if (evictedOrphanRef.current) {
+      return;
+    }
+    evictedOrphanRef.current = true;
+    if (chartKey != null && storedState && !restoredState) {
+      drillStateStore.delete(chartKey);
+    }
+    // Run once on mount; storedState/restoredState reflect the initial state.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Persist drill navigation synchronously so it survives remounts (see
+  // drillStateStore) without racing. Writing on a deferred effect would let a
+  // remount triggered by the same interaction (e.g. clearing a cross-filter)
+  // restore stale state before the effect runs, so the mutators below write
+  // through this helper immediately instead.
+  const persist = useCallback(
+    (
+      stack: DrillDownLevel[],
+      leaf: string | undefined,
+      leafFilters: BinaryQueryObjectFilterClause[] | undefined,
+    ) => {
+      if (chartKey == null) {
+        return;
+      }
+      if (stack.length === 0 && !leaf) {
+        drillStateStore.delete(chartKey);
+      } else {
+        drillStateStore.set(chartKey, {
+          drillStack: stack,
+          selectedLeaf: leaf,
+          selectedLeafFilters: leafFilters,
+          configKey,
+        });
+      }
+    },
+    [chartKey, configKey],
+  );
+
+  // Reset when the drill configuration changes, not just chart id or viz type.
+  // The drill stack is anchored to the primary dimension (x_axis or groupby)
+  // and the hierarchy list, so editing either — even without a viz-type change
+  // — must clear stale state whose next dimension no longer exists. A ref 
guard
+  // ensures the initial mount (which restores persisted state) does not wipe
+  // it, and that incidental re-renders from filter changes don't either.
+  // useLayoutEffect runs synchronously before paint so the stale drill state
+  // is cleared without a visible flash when the chart is reconfigured.
+  const prevConfigKeyRef = useRef(configKey);
+  useLayoutEffect(() => {
+    if (prevConfigKeyRef.current === configKey) {
+      return;
+    }
+    prevConfigKeyRef.current = configKey;
+    if (chartKey != null) {
+      drillStateStore.delete(chartKey);
+    }
+    setDrillStack([]);
+    setSelectedLeaf(undefined);
+    setSelectedLeafFilters(undefined);
+    setDrillData(null);
+    setError(undefined);
+  }, [configKey, chartKey]);
+
+  const hierarchy = useMemo<string[]>(() => {
+    const fd = formData as Record<string, unknown>;
+    const xAxis = fd.x_axis ?? fd.xAxis;
+
+    // Primary source: the dedicated `drilldown_hierarchy` control. The chart's
+    // own primary dimension (x_axis for axis charts, the first groupby column
+    // for groupby charts) is the top level and is prepended automatically when
+    // the author lists only the deeper levels.
+    const drillLevels = ensureIsArray(
+      fd[HIERARCHY_FIELD] ?? fd[HIERARCHY_FIELD_CAMEL],
+    ) as string[];
+    if (drillLevels.length > 0) {
+      // The primary dimension is always the initial (index 0) level, even if
+      // the author listed it later in the control; normalize it to the front
+      // (deduped) so the first drill advances off the primary dimension.
+      const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+      if (xAxisStr) {
+        return [xAxisStr, ...drillLevels.filter(col => col !== xAxisStr)];
+      }
+      const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
+        col => typeof col === 'string',
+      ) as string | undefined;
+      if (firstGroupby) {
+        return [
+          firstGroupby,
+          ...drillLevels.filter(col => col !== firstGroupby),
+        ];
+      }
+      return drillLevels;
+    }
+
+    return [];
+  }, [formData]);
+
+  // A hierarchy needs at least two levels to be drillable; a single column
+  // (e.g. the author listed only the chart's own dimension) is a no-op that
+  // would otherwise hijack the normal cross-filter click without ever
+  // advancing.
+  const hasHierarchy = hierarchy.length >= 2;
+  const currentDepth = drillStack.length;
+
+  const effectiveFormData = useMemo<QueryFormData>(() => {
+    if (currentDepth === 0) {
+      return formData;
+    }
+    const nextColumn = hierarchy[currentDepth];
+
+    // Merge accumulated filters from every level into adhoc_filters.
+    const accumulatedFilters = drillStack.flatMap(level => level.filters);
+    const baseAdhoc = ensureIsArray(
+      (formData as Record<string, unknown>)[DEFAULT_ADHOC_FILTERS_FIELD],
+    );
+
+    const fdRecord = formData as Record<string, unknown>;
+
+    // Swap the field the hierarchy is anchored to. When the chart has an
+    // x-axis, the hierarchy is x-axis driven (the groupby, if any, is only a
+    // series breakdown and must be preserved), so swap x_axis. Only groupby-
+    // based charts (Pie/Funnel/…, no x_axis) swap the groupby.
+    const xAxisIsSet =

Review Comment:
   An `AdhocColumn` is a supported `x_axis` value, but this string-only check 
classifies it as no x-axis. Drilling then rewrites `groupby` while leaving the 
actual x-axis unchanged; could this use `isXAxisSet`/`getXAxisLabel` so Custom 
SQL axes follow the axis path?



##########
superset-frontend/packages/superset-ui-chart-controls/src/sections/drilldownHierarchy.tsx:
##########
@@ -0,0 +1,73 @@
+/**
+ * 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/translation';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
+import { ControlPanelSectionConfig } from '../types';
+import { dndGroupByControl } from '../shared-controls/dndControls';
+
+/**
+ * Generic, reusable control panel section that lets the chart author define
+ * an ordered drill-down hierarchy. When the user clicks a data point on a
+ * dashboard chart, the host advances to the next level of this hierarchy by
+ * swapping the chart's dimension and adding a filter for the clicked value.
+ * A breadcrumb above the chart lets the user step back up.
+ *
+ * The list is stored in form_data as `drilldown_hierarchy`: string[] where
+ * each entry is a column name. Order matters — the chart's own x-axis (or
+ * groupby) is the top level shown initially, and each entry is a level the
+ * user reaches by clicking, in order. The x-axis column is prepended
+ * automatically if it is not already listed.
+ *
+ * This control does NOT affect the base (undrilled) query — it only
+ * configures what happens on click, so `x_axis` stays a single scalar column
+ * for every existing chart.
+ */
+export const drilldownHierarchySection: ControlPanelSectionConfig = {
+  label: t('Drill-down hierarchy'),
+  expanded: false,
+  controlSetRows: [
+    [
+      {
+        name: 'drilldown_hierarchy',
+        config: {
+          // Reuse the drag-and-drop column selector used for "Dimensions"
+          // so authors can reorder levels by dragging.
+          ...dndGroupByControl,
+          label: t('Drill-down levels'),
+          description: t(
+            'Ordered list of columns to drill into when a user clicks ' +
+              "a data point. The chart's primary dimension is shown " +
+              'initially. Each click drills to the next level, scoped to the ' 
+
+              'clicked value. Drag rows to reorder levels.',
+          ),
+          default: [],
+          // The drill levels must be plain column references (the drill logic
+          // matches them by name), so disallow ad-hoc/custom SQL columns.
+          freeForm: false,

Review Comment:
   `DndColumnSelect` does not use `freeForm`, so Custom SQL remains selectable 
here even though the drill state and breadcrumb treat every hierarchy entry as 
a string. An `AdhocColumn` can therefore be written into the next dimension or 
rendered as a React child; could this disable the `sqlExpression` tab (or 
normalize ad-hoc columns throughout)?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to