You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "tomerkl65 (via GitHub)" <gi...@apache.org> on 2026/07/09 13:23:52 UTC
[PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
tomerkl65 opened a new pull request, #41907:
URL: https://github.com/apache/superset/pull/41907
### SUMMARY
Adds a hierarchical drill-down interaction for ECharts charts. The chart author
defines an ordered list of columns in a new **Drill-down hierarchy** control.
On a dashboard, left-clicking a data point drills to the next level scoped to
the clicked value, shows a breadcrumb for navigating back up, and emits a
cross-filter for the full drill path so other charts stay in sync.
Design decisions:
- **Dedicated `drilldown_hierarchy` control** — the x-axis control stays a
single (scalar) column, so existing charts and all downstream query
consumers are unaffected. No global `multi` change, no feature flag needed.
- **Full-path cross-filter** — emitted centrally by the DrillDownHost (which
owns the drill stack), so downstream charts are scoped to the entire path,
not just the deepest clicked column.
- **Leaf selection** narrows the drilled chart to the clicked value at the
deepest level, consistently across charts.
Supported charts: Bar, Line, Area, Scatter, Smooth Line, Step (x-axis based)
and Pie, Funnel, Gauge, Radar, Box Plot (groupby based).
### TESTING INSTRUCTIONS
1. Create a Bar chart, set a single X-axis column, open **Drill-down hierarchy**
and add the deeper levels; save and add to a dashboard.
2. Click a bar → chart drills to the next level scoped to the clicked value; a
breadcrumb appears.
3. Confirm other dashboard charts are cross-filtered by the full path.
4. Use the breadcrumb to navigate back up; verify the chart restores.
5. Repeat with a groupby chart (e.g. Pie).
### ADDITIONAL INFORMATION
- [ ] Has associated issue:
- [ ] Required feature flags:
- [x] Changes UI
- [ ] Includes DB Migration
- [x] Introduces new feature or API
- [ ] Removes existing feature or API
<img width="1776" height="994" alt="image" src="https://github.com/user-attachments/assets/b0983fa5-010e-4899-bbf5-c0711c29e5c2" />
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5444331262
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3552076880
##########
superset-frontend/src/components/Chart/chartReducers.test.ts:
##########
@@ -67,6 +67,24 @@ describe('chart reducers', () => {
);
});
+ test('ignores a keyed action when the chart is missing from state', () => {
+ // A stale abort (CHART_UPDATE_STOPPED) can arrive after its chart was
+ // removed from state (e.g. during drill-down cross-filtering). The reducer
+ // must not throw when reading properties of the now-undefined chart.
+ expect(() =>
+ chartReducer(charts, actions.chartUpdateStopped(999)),
+ ).not.toThrow();
+ expect(chartReducer(charts, actions.chartUpdateStopped(999))).toBe(charts);
+ });
+
+ test('still applies a keyed action when the chart exists in state', () => {
+ const newState = chartReducer(
+ charts,
+ actions.chartUpdateSucceeded([], chartKey),
+ );
+ expect(newState[chartKey].chartStatus).toEqual('success');
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Incomplete test assertions</b></div>
<div id="fix">
The test only asserts `.chartStatus`, but the `CHART_UPDATE_SUCCEEDED` handler at chartReducer.ts:60-67 updates four properties. Per BITO.md rule 6262, assertions should validate the actual logic, not just one field. Add checks for `chartAlert` (should be null), `queriesResponse` (should match action arg), and `chartUpdateEndTime` (should be set).
</div>
</div>
<small><i>Code Review Run #6392bc</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,465 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} from 'src/components/Chart/chartAction';
+import { getQuerySettings } from 'src/explore/exploreUtils';
+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[];
+}
+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 {
+ formData: QueryFormData;
+ /** Original chart data, shown when the drill stack is empty */
+ baseQueriesResponse?: QueryData[] | null;
+}
+
+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({
+ formData,
+ baseQueriesResponse,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = formData.slice_id;
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+ () =>
+ (chartKey != null
+ ? drillStateStore.get(chartKey)?.drillStack
+ : undefined) ?? [],
+ );
+ const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(() =>
+ chartKey != null ? drillStateStore.get(chartKey)?.selectedLeaf : undefined,
+ );
+ // 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
+ >(() =>
+ chartKey != null
+ ? drillStateStore.get(chartKey)?.selectedLeafFilters
+ : undefined,
+ );
+ const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | undefined>();
+
+ // 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,
+ });
+ }
+ },
+ [chartKey],
+ );
+
+ // Reset only when the chart is actually reconfigured (chart id or viz type
+ // changes) — e.g. the dashboard owner edited the chart and saved. 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 configKey = `${formData.slice_id}__${formData.viz_type}`;
+ 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) {
+ const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+ if (xAxisStr) {
+ return drillLevels.includes(xAxisStr)
+ ? drillLevels
+ : [xAxisStr, ...drillLevels];
+ }
+ const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
+ col => typeof col === 'string',
+ ) as string | undefined;
+ if (firstGroupby && !drillLevels.includes(firstGroupby)) {
+ return [firstGroupby, ...drillLevels];
+ }
+ return drillLevels;
+ }
+
+ return [];
+ }, [formData]);
+
+ const hasHierarchy = hierarchy.length > 0;
+ 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>;
+
+ // Determine which field to swap with the next hierarchy level.
+ // Bar/Line/Area charts use x_axis as the primary dimension when groupby is empty.
+ const groupbyValue = fdRecord[DEFAULT_GROUPBY_FIELD];
+ const hasGroupby = Array.isArray(groupbyValue)
+ ? groupbyValue.length > 0
+ : !!groupbyValue;
+
+ const updated = { ...formData } as Record<string, unknown>;
+
+ if (hasGroupby) {
+ // Chart uses groupby — swap it
+ const isArrayGroupby = Array.isArray(groupbyValue);
+ updated[DEFAULT_GROUPBY_FIELD] = isArrayGroupby
+ ? [nextColumn]
+ : nextColumn;
+ } else if (fdRecord.x_axis !== undefined || fdRecord.xAxis !== undefined) {
+ // Chart uses x_axis (Bar/Line/Timeseries) — swap it
+ if (fdRecord.x_axis !== undefined) {
+ updated.x_axis = nextColumn;
+ }
+ if (fdRecord.xAxis !== undefined) {
+ updated.xAxis = nextColumn;
+ }
+ } else {
+ // Fallback: set groupby
+ updated[DEFAULT_GROUPBY_FIELD] = [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.
+ const leafFilters = 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, 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.
+ const effectiveFormDataKey = useMemo(
+ () => JSON.stringify(effectiveFormData),
+ [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;
+ let cancelled = false;
+ setIsLoading(true);
+ setError(undefined);
+
+ const [useLegacyApi] = getQuerySettings(activeFormData);
+
+ 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 { response, json } = await getChartDataRequest({
+ formData: activeFormData,
+ });
+ // eslint-disable-next-line no-await-in-loop
+ const queriesResponse = await handleChartDataResponse(
+ response,
+ json,
+ useLegacyApi,
+ );
+ if (!cancelled) {
+ setDrillData(queriesResponse as QueryData[]);
+ }
+ return;
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+ if (attempt < MAX_ATTEMPTS) {
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise(resolve => {
+ setTimeout(resolve, RETRY_DELAY_MS * attempt);
+ });
+ if (cancelled) {
+ return;
+ }
+ } else {
+ // eslint-disable-next-line no-await-in-loop
+ const message = await extractMessage(err);
+ // eslint-disable-next-line no-console
+ console.error('[DrillDown] query failed', {
+ message,
+ effectiveFormData: activeFormData,
+ });
+ if (!cancelled) {
+ setError(message);
+ }
+ }
+ }
+ }
+ };
+
+ runWithRetry().finally(() => {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ // effectiveFormDataKey is a stable serialization of effectiveFormData; the
+ // object itself is read via effectiveFormDataRef to avoid re-running on
+ // identity-only changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [effectiveFormDataKey, currentDepth]);
+
+ const drillDown = useCallback<UseDrillDownStateResult['drillDown']>(
+ (filters, label) => {
+ let nextStack: DrillDownLevel[];
+ let nextLeaf: string | undefined;
+ let nextLeafFilters: BinaryQueryObjectFilterClause[] | undefined;
+ // If we're at the last level, can't drill deeper — record the selection
+ // and keep its filters so the chart narrows to the picked leaf.
+ if (drillStack.length >= hierarchy.length - 1) {
+ nextStack = drillStack;
+ nextLeaf = label;
+ nextLeafFilters = filters;
+ } else {
+ // Clear any previous leaf selection when drilling deeper
+ nextStack = [...drillStack, { filters, label }];
+ nextLeaf = undefined;
+ nextLeafFilters = undefined;
+ }
+ setDrillStack(nextStack);
+ setSelectedLeaf(nextLeaf);
+ setSelectedLeafFilters(nextLeafFilters);
+ persist(nextStack, nextLeaf, nextLeafFilters);
+ },
+ [drillStack, hierarchy, persist],
+ );
+
+ const resetTo = useCallback(
+ (depth: number) => {
+ const nextStack = drillStack.slice(0, depth);
+ setDrillStack(nextStack);
+ setSelectedLeaf(undefined);
+ setSelectedLeafFilters(undefined);
+ persist(nextStack, undefined, undefined);
+ },
+ [drillStack, persist],
+ );
+
+ return {
+ isDrilling: currentDepth > 0,
+ drillStack,
+ selectedLeaf,
+ hierarchy,
+ effectiveFormData,
+ effectiveQueriesResponse:
+ currentDepth === 0 ? baseQueriesResponse : drillData,
+ isLoading,
+ error,
+ hasHierarchy,
+ drillDown,
+ resetTo,
+ };
+}
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>No test coverage for retry logic</b></div>
<div id="fix">
The test file has zero coverage for the retry logic (MAX_ATTEMPTS, RETRY_DELAY_MS, runWithRetry, extractMessage). Without tests, the retry behavior cannot be verified, and regressions in error handling or loading-state management will go undetected.
</div>
</div>
<div id="suggestion">
<div id="issue"><b>isLoading stuck true on unmount</b></div>
<div id="fix">
The cleanup function sets `cancelled = true` but never calls `setIsLoading(false)`. The `finally` block that calls `setIsLoading(false)` only runs when `runWithRetry()` settles — if the component unmounts before that (e.g., user navigates away mid-query), `isLoading` remains `true` in React state. Move the loading-reset into the cleanup return function.
</div>
</div>
<small><i>Code Review Run #6392bc</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4927443546
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3554406563
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.test.ts:
##########
@@ -0,0 +1,636 @@
+/**
+ * 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 { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryFormData } from '@superset-ui/core';
+import { useDrillDownState, clearDrillDownState } from './useDrillDownState';
+
+jest.mock('src/components/Chart/chartAction', () => ({
+ getChartDataRequest: jest.fn(() =>
+ Promise.resolve({ response: {}, json: { result: [{ data: [] }] } }),
+ ),
+ handleChartDataResponse: jest.fn(() =>
+ Promise.resolve([{ data: [{ col1: 'val1' }] }]),
+ ),
+}));
+
+jest.mock('src/explore/exploreUtils', () => ({
+ getQuerySettings: jest.fn(() => [false]),
+}));
+
+jest.mock('src/utils/simpleFilterToAdhoc', () => ({
+ simpleFilterToAdhoc: jest.fn(filter => ({
+ expressionType: 'SIMPLE',
+ clause: 'WHERE',
+ operator: filter.op,
+ subject: filter.col,
+ comparator: filter.val,
+ })),
+}));
+
+const baseFormData: QueryFormData = {
+ datasource: '1__table',
+ viz_type: 'echarts_timeseries_bar',
+ slice_id: 42,
+ x_axis: 'country',
+ groupby: [],
+ adhoc_filters: [],
+};
+
+beforeEach(() => {
+ // Drill state persists in a module-level store keyed by chart id; clear it
+ // so each test starts from a clean slate (tests reuse slice_id 42).
+ clearDrillDownState();
+});
+
+test('hierarchy is empty when x_axis is a single string with no drilldown_hierarchy', () => {
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData: baseFormData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(result.current.hierarchy).toEqual([]);
+ expect(result.current.hasHierarchy).toBe(false);
+});
+
+test('hierarchy is built from drilldown_hierarchy field', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: 'country',
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(result.current.hierarchy).toEqual(['country', 'region', 'city']);
+ expect(result.current.hasHierarchy).toBe(true);
+});
+
+test('drillDown() pushes a level onto the stack', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+ expect(result.current.drillStack[0]).toEqual({
+ filters: [{ col: 'country', op: '==', val: 'USA' }],
+ label: 'USA',
+ });
+ expect(result.current.isDrilling).toBe(true);
+});
+
+test('drillDown() at last level sets selectedLeaf instead of pushing', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ // Drill to depth 1 (only one more level available: 'region')
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+
+ // Now we're at the last level; next drill should set selectedLeaf
+ act(() => {
+ result.current.drillDown(
+ [{ col: 'region', op: '==', val: 'Texas' }],
+ 'Texas',
+ );
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+ expect(result.current.selectedLeaf).toBe('Texas');
+});
+
+test('resetTo(0) clears the stack', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+
+ act(() => {
+ result.current.resetTo(0);
+ });
+
+ expect(result.current.drillStack).toHaveLength(0);
+ expect(result.current.isDrilling).toBe(false);
+ expect(result.current.selectedLeaf).toBeUndefined();
+});
+
+test('effectiveFormData swaps x_axis when drilling', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ // After drilling one level, the effective x_axis should be 'region'
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ expect(effective.x_axis).toBe('region');
+});
+
+test('effectiveFormData adds adhoc_filters from drill stack', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE' as const,
+ clause: 'WHERE' as const,
+ subject: 'year',
+ operator: '==' as const,
+ comparator: '2023',
+ },
+ ],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ const adhocFilters = effective.adhoc_filters as unknown[];
+
+ // Should contain the original filter plus the drill filter
+ expect(adhocFilters).toHaveLength(2);
+ // The first should be the original filter
+ expect(adhocFilters[0]).toEqual(expect.objectContaining({ subject: 'year' }));
+ // The second should be generated from the drill filter via simpleFilterToAdhoc
+ expect(adhocFilters[1]).toEqual(
+ expect.objectContaining({
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ }),
+ );
+});
+
+test('effectiveFormData swaps groupby when chart uses groupby', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: undefined,
+ groupby: ['country'],
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ adhoc_filters: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ expect(effective.groupby).toEqual(['region']);
+});
+
+test('fetches data when drilling and returns it as effectiveQueriesResponse', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ adhoc_filters: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [{ country: 'USA' }] }],
+ }),
+ );
+
+ // Before drilling, effectiveQueriesResponse is the base data
+ expect(result.current.effectiveQueriesResponse).toEqual([
+ { data: [{ country: 'USA' }] },
+ ]);
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.effectiveQueriesResponse).toEqual([
+ { data: [{ col1: 'val1' }] },
+ ]);
+});
+
+test('drill state survives a remount (persisted per chart id)', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const first = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ first.result.current.drillDown(
+ [{ col: 'country', op: '==', val: 'USA' }],
+ 'USA',
+ );
+ });
+
+ await waitFor(() => {
+ expect(first.result.current.isLoading).toBe(false);
+ });
+ expect(first.result.current.drillStack).toHaveLength(1);
+
+ // Simulate the dashboard grid remounting the chart (e.g. an unrelated
+ // filter change). The drill navigation must be restored, not lost.
+ first.unmount();
+
+ const second = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(second.result.current.drillStack).toHaveLength(1);
+ expect(second.result.current.drillStack[0].label).toEqual('USA');
+ expect(second.result.current.isDrilling).toBe(true);
+});
+
+test('reconfiguring the chart (viz type change) clears persisted drill state', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result, rerender } = renderHook(
+ ({ fd }) =>
+ useDrillDownState({
+ chartId: 42,
+ formData: fd,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ { initialProps: { fd: formData } },
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ expect(result.current.drillStack).toHaveLength(1);
+
+ // A genuine reconfiguration (different viz type) resets the drill.
+ rerender({ fd: { ...formData, viz_type: 'echarts_timeseries_line' } });
+
+ expect(result.current.drillStack).toHaveLength(0);
+ expect(result.current.isDrilling).toBe(false);
+});
+
+test('effectiveFormData narrows to the selected leaf at the deepest level', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ groupby: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ // Drill into 'country' — now showing the 'region' level.
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ // Pick a value at the deepest level ('region').
+ act(() => {
+ result.current.drillDown(
+ [{ col: 'region', op: '==', val: 'Texas' }],
+ 'Texas',
+ );
+ });
+
+ expect(result.current.selectedLeaf).toBe('Texas');
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ // The x_axis stays on the deepest column.
+ expect(effective.x_axis).toBe('region');
+
+ const adhocFilters = effective.adhoc_filters as Record<string, unknown>[];
+ // Both the accumulated ancestor filter and the picked leaf filter apply,
+ // so the chart narrows to the single selected leaf.
+ expect(adhocFilters).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ subject: 'country', comparator: 'USA' }),
+ expect.objectContaining({ subject: 'region', comparator: 'Texas' }),
+ ]),
+ );
+});
+
+test('resetTo clears the selected leaf filter', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ groupby: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+ act(() => {
+ result.current.drillDown(
+ [{ col: 'region', op: '==', val: 'Texas' }],
+ 'Texas',
+ );
+ });
+
+ expect(result.current.selectedLeaf).toBe('Texas');
+
+ // Navigating back to the top level clears the leaf selection and its filter.
+ act(() => {
+ result.current.resetTo(1);
+ });
+
+ expect(result.current.selectedLeaf).toBeUndefined();
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ const adhocFilters = effective.adhoc_filters as Record<string, unknown>[];
+ expect(adhocFilters).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ subject: 'region', comparator: 'Texas' }),
+ ]),
+ );
+});
+
+test('hierarchy prepends the first groupby column for groupby-based charts', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: undefined,
+ groupby: ['parent'],
+ drilldown_hierarchy: ['id', 'city'],
+ } as unknown as QueryFormData;
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ // The chart's first groupby column is the top level, prepended when the
+ // author lists only the deeper levels.
+ expect(result.current.hierarchy).toEqual(['parent', 'id', 'city']);
+});
+
+test('effectiveFormData advances x_axis (not groupby) when the chart has both', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: 'country',
+ groupby: ['gender'],
+ drilldown_hierarchy: ['country', 'region'],
+ } as unknown as QueryFormData;
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ // The hierarchy is x-axis driven: advance x_axis and keep the groupby as a
+ // series breakdown (do NOT drill on the series dimension).
+ expect(effective.x_axis).toBe('region');
+ expect(effective.groupby).toEqual(['gender']);
+});
+
+test('a single-level hierarchy is not drillable', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: 'country',
+ drilldown_hierarchy: ['country'],
+ } as unknown as QueryFormData;
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ // Only the chart's own dimension — nothing to drill into.
+ expect(result.current.hasHierarchy).toBe(false);
+});
+
+test('retry logic: succeeds on second attempt after a transient failure', async () => {
+ const { getChartDataRequest } = jest.requireMock(
+ 'src/components/Chart/chartAction',
+ );
+ getChartDataRequest
+ .mockRejectedValueOnce(new Error('transient'))
+ .mockResolvedValueOnce({ response: {}, json: { result: [{ data: [] }] } });
+
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ } as unknown as QueryFormData;
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.effectiveQueriesResponse).not.toBeNull();
+});
+
+test('retry logic: surfaces error after all attempts are exhausted', async () => {
+ jest.useFakeTimers();
+ const { getChartDataRequest } = jest.requireMock(
+ 'src/components/Chart/chartAction',
+ );
+ getChartDataRequest.mockRejectedValue(new Error('persistent failure'));
+
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ } as unknown as QueryFormData;
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ // Advance through the retry delays (400ms * attempt for each of 2 retries)
+ for (let i = 0; i < 3; i += 1) {
+ // eslint-disable-next-line no-await-in-loop
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ });
+ }
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(result.current.error).toBeDefined();
+ expect(result.current.effectiveQueriesResponse).toBeNull();
+
+ // Restore
+ jest.useRealTimers();
+ getChartDataRequest.mockResolvedValue({
+ response: {},
+ json: { result: [{ data: [] }] },
+ });
+});
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Timer pollution from missing cleanup</b></div>
<div id="fix">
The test `'retry logic: surfaces error after all attempts are exhausted'` calls `jest.useFakeTimers()` at line 568 but only restores with `jest.useRealTimers()` at line 604 inside the test body. If any assertion before line 604 throws, Jest's timer mode stays fake and bleeds into all subsequent tests in this file (they share the same module scope). Add a module-level `afterEach` to guarantee cleanup.
</div>
</div>
<small><i>Code Review Run #660ac1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3829024935
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,483 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} 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[];
+}
+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;
+}
+
+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,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = chartId;
+
+ // 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) and via clearDrillDownState.
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+ () =>
+ (chartKey != null
+ ? drillStateStore.get(chartKey)?.drillStack
+ : undefined) ?? [],
+ );
+ const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(() =>
+ chartKey != null ? drillStateStore.get(chartKey)?.selectedLeaf : undefined,
+ );
+ // 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
+ >(() =>
+ chartKey != null
+ ? drillStateStore.get(chartKey)?.selectedLeafFilters
+ : undefined,
+ );
+ const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | undefined>();
+
+ // 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,
+ });
+ }
+ },
+ [chartKey],
+ );
+
+ // Reset only when the chart is actually reconfigured (chart id or viz type
+ // changes) — e.g. the dashboard owner edited the chart and saved. 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 configKey = `${chartId}__${formData.viz_type}`;
+ 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) {
+ const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+ if (xAxisStr) {
+ return drillLevels.includes(xAxisStr)
+ ? drillLevels
+ : [xAxisStr, ...drillLevels];
+ }
+ const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
+ col => typeof col === 'string',
+ ) as string | undefined;
+ if (firstGroupby && !drillLevels.includes(firstGroupby)) {
+ return [firstGroupby, ...drillLevels];
+ }
+ 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.
+ const leafFilters = selectedLeaf ? (selectedLeafFilters ?? []) : [];
+
+ updated[DEFAULT_ADHOC_FILTERS_FIELD] = [
+ ...baseAdhoc,
+ ...accumulatedFilters.map(f => simpleFilterToAdhoc(f)),
Review Comment:
This conversion drops the `grain` attached to time-axis drill filters, so a bucketed time-series click becomes an exact raw-timestamp equality rather than the bucket predicate. Clicking a daily/monthly point can therefore return no next-level rows even though the source bucket contains data. Could the drill filter conversion preserve the time grain and add a bucketed time-axis regression?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3829017546
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.test.ts:
##########
@@ -0,0 +1,640 @@
+/**
+ * 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 { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryFormData } from '@superset-ui/core';
+import { useDrillDownState, clearDrillDownState } from './useDrillDownState';
+
+jest.mock('src/components/Chart/chartAction', () => ({
+ getChartDataRequest: jest.fn(() =>
+ Promise.resolve({ response: {}, json: { result: [{ data: [] }] } }),
+ ),
+ handleChartDataResponse: jest.fn(() =>
+ Promise.resolve([{ data: [{ col1: 'val1' }] }]),
+ ),
+}));
+
+jest.mock('src/explore/exploreUtils', () => ({
+ getQuerySettings: jest.fn(() => [false]),
+}));
+
+jest.mock('src/utils/simpleFilterToAdhoc', () => ({
+ simpleFilterToAdhoc: jest.fn(filter => ({
+ expressionType: 'SIMPLE',
+ clause: 'WHERE',
+ operator: filter.op,
+ subject: filter.col,
+ comparator: filter.val,
+ })),
+}));
+
+const baseFormData: QueryFormData = {
+ datasource: '1__table',
+ viz_type: 'echarts_timeseries_bar',
+ slice_id: 42,
+ x_axis: 'country',
+ groupby: [],
+ adhoc_filters: [],
+};
+
+beforeEach(() => {
+ // Drill state persists in a module-level store keyed by chart id; clear it
+ // so each test starts from a clean slate (tests reuse slice_id 42).
+ clearDrillDownState();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+test('hierarchy is empty when x_axis is a single string with no drilldown_hierarchy', () => {
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData: baseFormData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(result.current.hierarchy).toEqual([]);
+ expect(result.current.hasHierarchy).toBe(false);
+});
+
+test('hierarchy is built from drilldown_hierarchy field', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: 'country',
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(result.current.hierarchy).toEqual(['country', 'region', 'city']);
+ expect(result.current.hasHierarchy).toBe(true);
+});
+
+test('drillDown() pushes a level onto the stack', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+ expect(result.current.drillStack[0]).toEqual({
+ filters: [{ col: 'country', op: '==', val: 'USA' }],
+ label: 'USA',
+ });
+ expect(result.current.isDrilling).toBe(true);
+});
+
+test('drillDown() at last level sets selectedLeaf instead of pushing', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ // Drill to depth 1 (only one more level available: 'region')
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+
+ // Now we're at the last level; next drill should set selectedLeaf
+ act(() => {
+ result.current.drillDown(
+ [{ col: 'region', op: '==', val: 'Texas' }],
+ 'Texas',
+ );
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+ expect(result.current.selectedLeaf).toBe('Texas');
+});
+
+test('resetTo(0) clears the stack', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ expect(result.current.drillStack).toHaveLength(1);
+
+ act(() => {
+ result.current.resetTo(0);
+ });
+
+ expect(result.current.drillStack).toHaveLength(0);
+ expect(result.current.isDrilling).toBe(false);
+ expect(result.current.selectedLeaf).toBeUndefined();
+});
+
+test('effectiveFormData swaps x_axis when drilling', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ // After drilling one level, the effective x_axis should be 'region'
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ expect(effective.x_axis).toBe('region');
+});
+
+test('effectiveFormData adds adhoc_filters from drill stack', () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE' as const,
+ clause: 'WHERE' as const,
+ subject: 'year',
+ operator: '==' as const,
+ comparator: '2023',
+ },
+ ],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ const adhocFilters = effective.adhoc_filters as unknown[];
+
+ // Should contain the original filter plus the drill filter
+ expect(adhocFilters).toHaveLength(2);
+ // The first should be the original filter
+ expect(adhocFilters[0]).toEqual(expect.objectContaining({ subject: 'year' }));
+ // The second should be generated from the drill filter via simpleFilterToAdhoc
+ expect(adhocFilters[1]).toEqual(
+ expect.objectContaining({
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ }),
+ );
+});
+
+test('effectiveFormData swaps groupby when chart uses groupby', () => {
+ const formData = {
+ ...baseFormData,
+ x_axis: undefined,
+ groupby: ['country'],
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ adhoc_filters: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ const effective = result.current.effectiveFormData as Record<string, unknown>;
+ expect(effective.groupby).toEqual(['region']);
+});
+
+test('fetches data when drilling and returns it as effectiveQueriesResponse', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ groupby: [],
+ adhoc_filters: [],
+ };
+
+ const { result } = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [{ country: 'USA' }] }],
+ }),
+ );
+
+ // Before drilling, effectiveQueriesResponse is the base data
+ expect(result.current.effectiveQueriesResponse).toEqual([
+ { data: [{ country: 'USA' }] },
+ ]);
+
+ act(() => {
+ result.current.drillDown([{ col: 'country', op: '==', val: 'USA' }], 'USA');
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.effectiveQueriesResponse).toEqual([
+ { data: [{ col1: 'val1' }] },
+ ]);
+});
+
+test('drill state survives a remount (persisted per chart id)', async () => {
+ const formData = {
+ ...baseFormData,
+ drilldown_hierarchy: ['country', 'region', 'city'],
+ };
+
+ const first = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ act(() => {
+ first.result.current.drillDown(
+ [{ col: 'country', op: '==', val: 'USA' }],
+ 'USA',
+ );
+ });
+
+ await waitFor(() => {
+ expect(first.result.current.isLoading).toBe(false);
+ });
+ expect(first.result.current.drillStack).toHaveLength(1);
+
+ // Simulate the dashboard grid remounting the chart (e.g. an unrelated
+ // filter change). The drill navigation must be restored, not lost.
+ first.unmount();
+
+ const second = renderHook(() =>
+ useDrillDownState({
+ chartId: 42,
+ formData,
+ baseQueriesResponse: [{ data: [] }],
+ }),
+ );
+
+ expect(second.result.current.drillStack).toHaveLength(1);
+ expect(second.result.current.drillStack[0].label).toEqual('USA');
+ expect(second.result.current.isDrilling).toBe(true);
+});
+
+test('reconfiguring the chart (viz type change) clears persisted drill state', async () => {
Review Comment:
This only resets persisted drill state when the chart ID or viz type changes, even though that state also depends on the primary dimension and hierarchy. Editing a chart's x-axis or shortening its hierarchy without changing its viz type restores the old stack; the next drill can use stale filters and an undefined next dimension. Could the reset key include the drill configuration and cover that same-ID/same-viz reconfiguration?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3573618413
##########
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:
**Suggestion:** Adding this section unconditionally exposes an empty “Drill-down hierarchy” panel when the `DrillDown` feature flag is off, because only the inner control has `visibility` gating. Gate the section itself (or conditionally add it) so users don’t see a blank section in disabled environments. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
⚠️ Explore Bar chart shows empty Drill-down hierarchy section.
⚠️ Users see feature label without any usable controls.
⚠️ Same empty section on Area, Step, Pie charts.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Disable the DRILL_DOWN feature flag as shown in
`superset-frontend/src/components/Chart/DrillDown/DrillDownHost.test.tsx:163-166`, where
`global.featureFlags[FeatureFlag.DrillDown]` is set to `false`.
2. Open Explore for a Bar chart, which uses the control panel config in
`superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx:15-22`;
note `controlPanelSections` includes `sections.drilldownHierarchySection` at line 317
unconditionally.
3. The section `drilldownHierarchySection` is defined in
`superset-frontend/packages/superset-ui-chart-controls/src/sections/drilldownHierarchy.tsx:2-34`
with label `t('Drill-down hierarchy')` and a single control `drilldown_hierarchy` whose
`config.visibility` is `() => isFeatureEnabled(FeatureFlag.DrillDown)` at line 29, so this
control is hidden when the flag is disabled.
4. In Explore, sections are rendered by `renderControlPanelSection` in
`superset-frontend/src/explore/components/ControlPanelsContainer.tsx:593-597` and 629-135;
section visibility uses only `section.visibility` (`isVisible = visibility?.call(...) !==
false` at line 14), which is undefined for `drilldownHierarchySection`, so the section
remains visible with `style: { display: isVisible ? 'block' : 'none' }` at line 135, while
its only row is dropped because `renderedControls.length === 0` at lines 104-107. The
result is a visible "Drill-down hierarchy" header with no controls whenever the DRILL_DOWN
feature flag is off.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=af9525b502be41598773d7a62a29c04d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=af9525b502be41598773d7a62a29c04d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
**Line:** 317:317
**Comment:**
*Logic Error: Adding this section unconditionally exposes an empty “Drill-down hierarchy” panel when the `DrillDown` feature flag is off, because only the inner control has `visibility` gating. Gate the section itself (or conditionally add it) so users don’t see a blank section in disabled environments.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=82e79493df1cf1a5cee106bfffd480de5e8c23f650737dfebde7a92ea1a13c26&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=82e79493df1cf1a5cee106bfffd480de5e8c23f650737dfebde7a92ea1a13c26&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3559092786
##########
superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts:
##########
@@ -335,6 +335,7 @@ export default function transformProps(
groupby,
selectedValues,
onContextMenu,
+ onDrillDown,
Review Comment:
**Suggestion:** This adds drill-down callback plumbing, but Box Plot still has no `drilldown_hierarchy` control in its control panel, so users cannot configure a hierarchy and the host will never provide this callback in normal UI flows. Add `sections.drilldownHierarchySection` to the Box Plot control panel (like Pie/Funnel/Gauge) so this path is reachable. [incomplete implementation]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ Box Plot charts cannot configure drill-down hierarchy.
- ⚠️ Dashboard drill-down inconsistent across supported ECharts charts.
- ⚠️ Box Plot drill-down plumbing remains unreachable in normal flows.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Create a Box Plot chart in Explore using the Box Plot plugin registered in
`superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts:21-68`, which wires
`controlPanel` from `src/BoxPlot/controlPanel.ts` (read at lines 2-140) into the chart
configuration.
2. Inspect the Box Plot control panel configuration in `src/BoxPlot/controlPanel.ts:2-140`
and note that `controlPanelSections` only includes the "Query" section,
`sections.titleControls`, and a "Chart Options" section; unlike
Pie/Gauge/Funnel/Timeseries control panels (e.g. `src/Pie/controlPanel.tsx:11`,
`src/Funnel/controlPanel.tsx:15`, `src/Gauge/controlPanel.tsx:21`,
`src/Timeseries/Regular/Bar/controlPanel.tsx:317`) there is no
`sections.drilldownHierarchySection`, so users have no way in the UI to set the
`drilldown_hierarchy` form-data field for Box Plot.
3. Add the Box Plot chart to a dashboard so it is rendered through `Chart.tsx`, which
wraps charts in `DrillDownHost` (`superset-frontend/src/components/Chart/Chart.tsx:359`);
`DrillDownHost` calls `useDrillDownState` and derives `hierarchy` from
`drilldown_hierarchy` (`useDrillDownState.ts:47-15`) and computes `hasHierarchy`
(`useDrillDownState.ts:35-39`). Because the Box Plot control panel never populates
`drilldown_hierarchy`, `hierarchy` is an empty array and `hasHierarchy` is false, so
`drillEnabled` is false (`DrillDownHost.tsx:44-48`) and `onDrillDown` remains `undefined`,
with the host taking the pass-through path that returns <ChartRendererComponent
{...rendererProps} /> without an `onDrillDown` prop (`DrillDownHost.tsx:173-175).
4. When the dashboard user clicks a Box Plot data point, the ECharts renderer
`EchartsBoxPlot` (`src/BoxPlot/EchartsBoxPlot.tsx:23-37`) calls `allEventHandlers(props)`
to wire interactions based on the transformed props from `transformProps`
(`src/BoxPlot/transformProps.ts:7-22, 288-302), where `hooks` is destructured as `{
setDataMask = () => {}, onContextMenu, onDrillDown }` and `onDrillDown` is always
`undefined` in normal UI flows. As a result, no drill-down callback is invoked, no
breadcrumb is rendered (because `DrillDownHost` never enabled drilling), and the newly
added `onDrillDown` plumbing for Box Plot remains unreachable, making drill-down
effectively unusable for this chart type.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=90c1becb9955485fbf44ad2a0107366e&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=90c1becb9955485fbf44ad2a0107366e&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts
**Line:** 338:338
**Comment:**
*Incomplete Implementation: This adds drill-down callback plumbing, but Box Plot still has no `drilldown_hierarchy` control in its control panel, so users cannot configure a hierarchy and the host will never provide this callback in normal UI flows. Add `sections.drilldownHierarchySection` to the Box Plot control panel (like Pie/Funnel/Gauge) so this path is reachable.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=4287bc7150949fc2ed810d2f14a954624bd88805fcc4ff62d204475db2838368&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=4287bc7150949fc2ed810d2f14a954624bd88805fcc4ff62d204475db2838368&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3574783200
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownBreadcrumb.test.tsx:
##########
@@ -0,0 +1,138 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, fireEvent } from 'spec/helpers/testing-library';
+import { DrillDownBreadcrumb } from './DrillDownBreadcrumb';
+import { DrillDownLevel } from './types';
+
+const hierarchy = ['country', 'region', 'city'];
+
+const drillStack: DrillDownLevel[] = [
+ {
+ filters: [{ col: 'country', op: '==', val: 'USA' }],
+ label: 'USA',
+ },
+ {
+ filters: [{ col: 'region', op: '==', val: 'Texas' }],
+ label: 'Texas',
+ },
+];
+
+test('breadcrumb returns null when drillStack is empty and no selectedLeaf', () => {
+ const { container } = render(
+ <DrillDownBreadcrumb
+ hierarchy={hierarchy}
+ drillStack={[]}
+ onJumpTo={jest.fn()}
+ />,
+ );
+
+ expect(container.firstChild).toBeNull();
+});
+
+test('breadcrumb renders hierarchy[0] and drill labels', () => {
+ render(
+ <DrillDownBreadcrumb
+ hierarchy={hierarchy}
+ drillStack={drillStack}
+ onJumpTo={jest.fn()}
+ />,
+ );
+
+ // The root column name should be rendered
+ expect(screen.getByText('country')).toBeInTheDocument();
+ // Drill labels should be rendered
+ expect(screen.getByText('USA')).toBeInTheDocument();
+ expect(screen.getByText('Texas')).toBeInTheDocument();
+});
+
+test('clicking a breadcrumb segment calls onJumpTo with correct depth', () => {
+ const onJumpTo = jest.fn();
+
+ render(
+ <DrillDownBreadcrumb
+ hierarchy={hierarchy}
+ drillStack={drillStack}
+ onJumpTo={onJumpTo}
+ />,
+ );
+
+ // Click the root segment (hierarchy[0]) — should call onJumpTo(0)
+ fireEvent.click(screen.getByText('country'));
+ expect(onJumpTo).toHaveBeenCalledWith(0);
+
+ // Click the first drill label 'USA' — should call onJumpTo(1)
+ fireEvent.click(screen.getByText('USA'));
+ expect(onJumpTo).toHaveBeenCalledWith(1);
+});
+
+test('clickable breadcrumb segments are keyboard accessible', () => {
+ render(
+ <DrillDownBreadcrumb
+ hierarchy={hierarchy}
+ drillStack={drillStack}
+ onJumpTo={jest.fn()}
+ />,
+ );
+
+ // Clickable segments expose role="button" and are focusable via tabIndex so
+ // they remain operable by keyboard (Enter/Space) without a native <button>.
+ const root = screen.getByText('country');
+ expect(root).toHaveAttribute('role', 'button');
+ expect(root).toHaveAttribute('tabindex', '0');
+ expect(screen.getByText('USA')).toHaveAttribute('role', 'button');
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Incomplete tabindex assertion</b></div>
<div id="fix">
Test claims all clickable segments are 'focusable via tabIndex' (line 93-94 comment) but only verifies `tabindex` on the 'country' element, not on 'USA'. Both segments are created by the same `clickable()` function which sets `tabIndex={0}` (DrillDownBreadcrumb.tsx:68). Inconsistent assertion weakens accessibility coverage.
</div>
</div>
<small><i>Code Review Run #927598</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
docs/docs/using-superset/exploring-data.mdx:
##########
@@ -329,6 +329,26 @@ various options in this section, refer to the
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
+ dashboard follows along. It answers "let me navigate this dimension hierarchy by clicking."
+
+Drill to detail and Drill by are context-menu actions that open modals; the drill-down hierarchy is
+an in-place, click-to-navigate interaction on the dashboard itself.
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>CWE-1104: Deprecated Feature Documented Without Notice</b></div>
<div id="fix">
The `DRILL_TO_DETAIL` feature flag is deprecated (config.py line 936, lifecycle: deprecated) and was deprecated in version 5.0 (CHANGELOG/5.0.0.md line 462). The documentation should note this status to avoid misleading users about a deprecated feature. Also, the description for `DRILL_DOWN` implies it follows the same Behavior enum pattern as DrillToDetail/DrillBy, but `Behavior.DrillDown` does not exist in the Behavior enum (Base.ts lines 25-36); the drill-down feature uses a separate DrillDownHost.tsx implementation on dashboards. (See also: [CWE-1104](https://cwe.mitre.org/data/definitions/1104.html))
</div>
</div>
<small><i>Code Review Run #927598</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5787669021
> @rusackas in light of the community responses on the SIP, would you be comfortable lifting the hold label so we can move forward? Thanks!
The [SIP](https://github.com/apache/superset/issues/43331) hasn't been (as far as I see) taken to the dev@ mailing list for consensus/voting as a [DISCUSS] thread. I've never seen so much instant engagement on a SIP issue on GitHub, so it made me raise a suspicious eyebrow, but if that's real, let's take it to the listserv and run it through the actual SIP process.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r4024147308
##########
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 should be gated at the section level so it is removed entirely when `DRILL_DOWN` is disabled, rather than leaving an empty panel.
Update the reusable section definition:
```tsx
export const drilldownHierarchySection: ControlPanelSectionConfig = {
label: t('Drill-down hierarchy'),
expanded: false,
visibility: () => isFeatureEnabled(FeatureFlag.DrillDown),
controlSetRows: [
[
{
name: 'drilldown_hierarchy',
config: {
...dndGroupByControl,
// ...
},
},
],
],
};
```
The control-level `visibility` can then be removed because the enclosing section already provides the same guard. This fixes Bar and the other charts that reuse `sections.drilldownHierarchySection`, ensuring no empty panel appears with the flag off.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5454339161
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3829028855
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,292 @@
+/**
+ * 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,
+} from '@superset-ui/core';
+import { css } from '@apache-superset/core/theme';
+import { ChartSource } from 'src/types/ChartSource';
+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 `IN`-op 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.
+ */
+const toCrossFilterClauses = (filters: BinaryQueryObjectFilterClause[]) =>
Review Comment:
This turns a null drill value into `IN [null]`, which does not select SQL NULL rows. The existing ECharts cross-filter path emits `IS NULL` for this case. Clicking a null Pie/Funnel/groupby segment therefore empties linked charts instead of scoping them to null rows. Could this preserve the null operator and add that regression?
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,483 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} 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[];
+}
+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;
+}
+
+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,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = chartId;
+
+ // 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) and via clearDrillDownState.
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+ () =>
+ (chartKey != null
+ ? drillStateStore.get(chartKey)?.drillStack
+ : undefined) ?? [],
+ );
+ const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(() =>
+ chartKey != null ? drillStateStore.get(chartKey)?.selectedLeaf : undefined,
+ );
+ // 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
+ >(() =>
+ chartKey != null
+ ? drillStateStore.get(chartKey)?.selectedLeafFilters
+ : undefined,
+ );
+ const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | undefined>();
+
+ // 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,
+ });
+ }
+ },
+ [chartKey],
+ );
+
+ // Reset only when the chart is actually reconfigured (chart id or viz type
+ // changes) — e.g. the dashboard owner edited the chart and saved. 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 configKey = `${chartId}__${formData.viz_type}`;
+ 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) {
+ const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+ if (xAxisStr) {
+ return drillLevels.includes(xAxisStr)
Review Comment:
This accepts the primary dimension anywhere in the configured order, although the control contract says it is the initial level. With `x_axis: country` and `[region, country, city]`, the first drill remains on `country` and the next skips `region`. Could this normalize the primary dimension to index zero (or reject that ordering) and cover the reordered case?
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,483 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} 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[];
+}
+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;
+}
+
+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,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = chartId;
+
+ // 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) and via clearDrillDownState.
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+ () =>
+ (chartKey != null
+ ? drillStateStore.get(chartKey)?.drillStack
+ : undefined) ?? [],
+ );
+ const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(() =>
+ chartKey != null ? drillStateStore.get(chartKey)?.selectedLeaf : undefined,
+ );
+ // 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
+ >(() =>
+ chartKey != null
+ ? drillStateStore.get(chartKey)?.selectedLeafFilters
+ : undefined,
+ );
+ const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | undefined>();
+
+ // 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,
+ });
+ }
+ },
+ [chartKey],
+ );
+
+ // Reset only when the chart is actually reconfigured (chart id or viz type
+ // changes) — e.g. the dashboard owner edited the chart and saved. 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 configKey = `${chartId}__${formData.viz_type}`;
+ 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) {
+ const xAxisStr = typeof xAxis === 'string' ? xAxis : undefined;
+ if (xAxisStr) {
+ return drillLevels.includes(xAxisStr)
+ ? drillLevels
+ : [xAxisStr, ...drillLevels];
+ }
+ const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
+ col => typeof col === 'string',
+ ) as string | undefined;
+ if (firstGroupby && !drillLevels.includes(firstGroupby)) {
+ return [firstGroupby, ...drillLevels];
+ }
+ 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.
+ const leafFilters = selectedLeaf ? (selectedLeafFilters ?? []) : [];
Review Comment:
An empty-string category is a valid selected leaf but is falsy here. At the deepest level its filter is omitted from the drilled chart while the dashboard cross-filter still contains the empty value, so the two views diverge. Could this test selection presence rather than label truthiness and cover an empty-string leaf?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "tomerkl65 (via GitHub)" <gi...@apache.org>.
tomerkl65 commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4925551061
This supersedes #40449, which I had to close and recreate — the branch was
force-pushed so GitHub wouldn't let me reopen it. The code here is the same
feature, cleaned up and rebased on current master.
@michael-s-molina @villebro @EnxDev — following up on the earlier review, this
branch addresses the feedback:
- **No global `multi` change.** Reverted `xAxisMixin` to `multi: false`; the
hierarchy is now configured via a dedicated `drilldown_hierarchy` control, so
x_axis stays a scalar column and no downstream consumer (normalizeTimeColumn,
operators, backend QueryObject) is affected. No feature flag needed.
- **Treemap/Sunburst removed** from the supported set. Supported: Bar, Line,
Area, Scatter, Smooth Line, Step, Pie, Funnel, Gauge, Radar, Box Plot.
- **Backend engine-listener fix dropped** — it already landed on master
separately.
- **Dead code removed** — the unused `DrillDownEvent` type and `options` param
are gone; cross-filter emission is consolidated in the host.
- Breadcrumb uses native `<button>` (a11y) and theme spacing tokens.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5224447597
Just a ping here... could use more info here and touchups based on prior review comments.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4925482057
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r4033301897
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,364 @@
+/**
+ * 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 { selectAsyncModeOverride } from 'src/utils/asyncMode';
+import type { RequestParams } from 'src/components/Chart/chartAction';
+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;
+
+ // Request params for drill queries, mirroring exploreJSON so superseded
+ // drills abort, hung queries time out, and the per-dashboard async override
+ // is honored. The hook adds the per-request AbortSignal.
+ const asyncModeOverride = useSelector(selectAsyncModeOverride);
+ const dashboardId = useSelector<RootState, number | undefined>(
+ state => state.dashboardInfo?.id,
+ );
+ const webserverTimeout = useSelector<RootState, number | undefined>(
+ state => state.common?.conf?.SUPERSET_WEBSERVER_TIMEOUT,
+ );
+ const drillRequestParams = useMemo<RequestParams>(() => {
+ const params: RequestParams = {};
+ if (webserverTimeout) {
+ params.timeout = webserverTimeout * 1000;
+ }
+ if (dashboardId) {
+ params.dashboard_id = dashboardId;
+ }
+ if (asyncModeOverride) {
+ params.async_mode_override = asyncModeOverride;
+ }
+ return params;
+ }, [webserverTimeout, dashboardId, asyncModeOverride]);
+
+ const {
+ isDrilling,
+ drillStack,
+ selectedLeaf,
+ hierarchy,
+ effectiveFormData,
+ effectiveQueriesResponse,
+ isLoading,
+ error,
+ hasHierarchy,
+ drillDown,
+ resetTo,
+ } = useDrillDownState({
+ chartId: rendererProps.chartId,
+ formData,
+ baseQueriesResponse: queriesResponse,
+ crossFilterCleared,
+ requestParams: drillRequestParams,
+ });
+
+ // 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>(() => {
+ // Suspend drilling while a drill query is in error: the overlay has fallen
+ // back to the base chart, so a click here would append a base-level value
+ // as the next level and emit contradictory filters (e.g. country=USA plus
+ // country=Canada). The breadcrumb still allows navigating up, which
+ // re-queries and clears the error.
+ if (!drillEnabled || error != null) {
+ 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,
+ error,
+ 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,
+ queriesResponse: (effectiveQueriesResponse ?? null) as QueryData[] | null,
+ // Treat "no data yet" as loading. On the first render after a drill the
+ // fetch effect hasn't run (isLoading is still false, drillData is null);
+ // without this the renderer would briefly paint a "No results" state.
+ chartStatus:
Review Comment:
This sets only the nested renderer to `loading`; the dashboard spinner still follows the unchanged Redux chart status, while `ChartRenderer` returns `null` for this state. A slow drill therefore blanks the chart with no loading affordance for the whole request/retry window. Could this keep the prior data visible or render a drill-local spinner?
##########
superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts:
##########
@@ -167,16 +167,58 @@ export const allEventHandlers = (
selectedValues,
coltypeMapping,
formData,
+ onDrillDown,
} = transformedProps;
+
+ // When a drill-down hierarchy is configured, left-click drills instead of
+ // emitting a cross-filter. The DrillDownHost provides onDrillDown only when
+ // a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ const drillDownClickHandler =
+ hasDrillHierarchy && groupby.length > 0
+ ? (e: { name: string }) => {
+ const values = labelMap[e.name];
Review Comment:
When `threshold_for_other` creates the visible `Other` slice, that synthetic label has no `labelMap` entry, so clicking it silently returns without querying or updating the breadcrumb. Could this either represent the aggregate as a filter or make the `Other` slice explicitly non-drillable?
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,600 @@
+/**
+ * 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,
+ getXAxisLabel,
+ isQueryFormColumn,
+ isXAxisSet,
+ QueryData,
+ QueryFormData,
+} from '@superset-ui/core';
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
+import {
+ requestChartDataResolved,
+ type RequestParams,
+} 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;
+ /**
+ * Base request params (timeout, dashboard_id, async_mode_override) for drill
+ * queries, mirroring exploreJSON. The hook adds the per-request AbortSignal.
+ * Without these a superseded synchronous drill keeps running, hung queries
+ * lack the normal timeout, and per-dashboard async overrides are ignored.
+ */
+ requestParams?: RequestParams;
+}
+
+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,
+ requestParams,
+}: 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
+ }, []);
+
+ // Reset live when the owning cross-filter is cleared while the chart stays
+ // mounted (the user removes this chart's cross-filter from the filter bar).
+ // Only a false -> true transition counts: mount and the normal drill sequence
+ // (the click writes a non-empty mask, so crossFilterCleared stays false) must
+ // not trip this, otherwise an in-progress drill would reset itself.
+ const prevCrossFilterClearedRef = useRef(crossFilterCleared);
+ useLayoutEffect(() => {
+ const wasCleared = prevCrossFilterClearedRef.current;
+ prevCrossFilterClearedRef.current = crossFilterCleared;
+ if (!crossFilterCleared || wasCleared) {
+ return;
+ }
+ if (chartKey != null) {
+ drillStateStore.delete(chartKey);
+ }
+ setDrillStack([]);
+ setSelectedLeaf(undefined);
+ setSelectedLeafFilters(undefined);
+ setDrillData(null);
+ setError(undefined);
+ }, [crossFilterCleared, chartKey]);
+
+ // 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. An
+ // x-axis may be an ad-hoc (Custom SQL) column, so resolve its label
+ // rather than assuming a plain string.
+ const xAxisStr = isXAxisSet(formData)
+ ? getXAxisLabel(formData)
+ : typeof xAxis === 'string'
+ ? xAxis
+ : undefined;
+ if (xAxisStr) {
+ return [xAxisStr, ...drillLevels.filter(col => col !== xAxisStr)];
+ }
+ const firstGroupby = ensureIsArray(fd[DEFAULT_GROUPBY_FIELD]).find(
Review Comment:
A Custom SQL primary `groupby` is ignored here, so after the first click `currentDepth` advances against only the configured deeper levels: `[region, city]` jumps straight to `city`, and a single `[region]` leaves drilling disabled. Could this normalize the `AdhocColumn` label just as the x-axis branch does?
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/drillFilters.ts:
##########
@@ -0,0 +1,146 @@
+/**
+ * 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 {
+ AxisType,
+ BinaryQueryObjectFilterClause,
+ DTTM_ALIAS,
+ getNumberFormatter,
+ getTimeFormatter,
+ TimeGranularity,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { OrientationType } from './types';
+import { formatSeriesName } from '../utils/series';
+import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
+
+/**
+ * Resolve the category (x-axis) value from an ECharts click event. Horizontal
+ * bar charts hold the category at a different tuple index than vertical ones;
+ * fall back to the event `name` when the data tuple is unavailable.
+ */
+export function getCategoryAxisValue(
+ data: unknown,
+ name: unknown,
+ orientation?: OrientationType,
+): string | number | undefined {
+ const index = orientation === OrientationType.Horizontal ? 1 : 0;
+ if (Array.isArray(data)) {
+ const categoryAxisValue = data[index];
+ if (
+ typeof categoryAxisValue === 'string' ||
+ typeof categoryAxisValue === 'number'
+ ) {
+ return categoryAxisValue;
+ }
+ }
+ if (typeof name === 'string' || typeof name === 'number') {
+ return name;
+ }
+ return undefined;
+}
+
+export interface TimeseriesDrillFilterParams {
+ /** ECharts click event `componentType` — only `series` clicks drill. */
+ componentType?: string;
+ /** ECharts click event `data` tuple. */
+ data: unknown;
+ /** ECharts click event `name`. */
+ name: unknown;
+ /** The x-axis type (drilling only advances a category axis by value). */
+ xAxisType?: AxisType;
+ /** The x-axis column name the filter is built against. */
+ xAxisLabel: string;
+ orientation?: OrientationType;
+ dateFormat?: string;
+ numberFormat?: string;
+ /** Column type of the x-axis, used to format the breadcrumb label. */
+ coltype?: GenericDataType;
+ /** For a temporal x-axis: the real granularity column (maps __timestamp). */
+ granularitySqla?: string;
+ /** For a temporal x-axis: the active grain, for a bucket-range filter. */
+ timeGrain?: TimeGranularity;
+}
+
+/**
+ * Build the drill-down filter(s) for a Timeseries-family chart click.
+ *
+ * Timeseries charts are always x-axis driven: the hierarchy advances along the
+ * x-axis column and any groupby is only a series breakdown, so the drill filter
+ * is always keyed to the clicked x-axis value — never the series dimension.
+ * Clicks that are not on a series (e.g. axis labels, `componentType: 'xAxis'`)
+ * return no filters so they never trigger a drill.
+ *
+ * The breadcrumb/cross-filter label uses `formatSeriesName` for parity with the
+ * groupby drill path, so dates and decimals render formatted, not raw.
+ */
+export function buildTimeseriesDrillFilters({
+ componentType,
+ data,
+ name,
+ xAxisType,
+ xAxisLabel,
+ orientation,
+ dateFormat,
+ numberFormat,
+ coltype,
+ granularitySqla,
+ timeGrain,
+}: TimeseriesDrillFilterParams): BinaryQueryObjectFilterClause[] {
+ if (componentType !== 'series') {
+ return [];
+ }
+
+ const format = (value: string | number) =>
+ formatSeriesName(value, {
+ timeFormatter: getTimeFormatter(dateFormat),
+ numberFormatter: getNumberFormatter(numberFormat),
+ coltype,
+ });
+
+ // Orientation-aware x-axis value from the clicked series; falls back to the
+ // event name. Null check so zero-like labels (e.g. 0) still drill.
+ const axisValue = getCategoryAxisValue(data, name, orientation);
+ if (axisValue == null) {
+ return [];
+ }
+
+ if (xAxisType === AxisType.Time) {
+ // Mirror drill-to-detail: map __timestamp to the real granularity column
+ // and build a grain-aware TEMPORAL_RANGE so a bucketed click scopes to the
+ // whole bucket, not an exact-timestamp equality that matches no rows.
+ const col =
+ xAxisLabel === DTTM_ALIAS ? (granularitySqla ?? xAxisLabel) : xAxisLabel;
+ const filter = getTemporalXAxisDrillByFilter(
+ col,
+ axisValue,
+ timeGrain,
+ format(axisValue),
+ );
+ return filter ? [filter] : [];
+ }
+
+ return [
+ {
+ col: xAxisLabel,
Review Comment:
For a physical column with a verbose name—or a Custom SQL x-axis—this stores the display label in `col`, so `simpleFilterToAdhoc` treats `Order Date`/`country_expr` as a physical column instead of filtering `order_date`/the SQL expression. Could this preserve the original `QueryFormColumn` for the filter and use the label only for display?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5341964850
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "tomerkl65 (via GitHub)" <gi...@apache.org>.
tomerkl65 commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5552065773
@rusackas in light of the community responses on the SIP, would you be comfortable lifting the hold label so we can move forward? Thanks!
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4952958103
## EnxDev's Review Agent — apache/superset#41907 · HEAD db42179
**comment** — supersedes my prior [`request changes` (6f70126)](https://github.com/apache/superset/pull/41907#issuecomment-4925733575). The drill-path blocker and all four 🔴 findings are fixed; what's left is test coverage and a maintainer call on flagging/docs. No hard blocker remains.
Verified against the code, not the "fixed" claims:
- **x_axis + groupby charts now drill the x-axis, not the series dimension.** `useDrillDownState.ts` `xAxisIsSet` branch swaps `x_axis` and preserves `groupby`; `EchartsTimeseries.tsx` builds the filter from `xAxis.label`. Regression test present (`effectiveFormData advances x_axis (not groupby) when the chart has both`).
- `chartStatus: isLoading || effectiveQueriesResponse == null ? 'loading' : 'rendered'` — no more "No results" flash on drill.
- Axis-label guard restored (`props.componentType === 'series'`).
- `hasHierarchy = hierarchy.length >= 2` — a single-level hierarchy no longer hijacks the click. Test present.
- Explore gate (`source === ChartSource.Dashboard`), store keyed by `chartId` + eviction, `freeForm: false`, `renderTrigger: true`, `handleResetTo` gated on `emitCrossFilters`, and `console.error` no longer dumping form_data — all addressed. (`triggerQuery` and `updateDataMask` are confirmed bound on the dashboard chart's `actions` via `ChartContainer`, so the reset-to-root re-query is real, not a silent no-op.)
### 🟡 Should-fix
- **`plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx`** — the ~60-line inline drill handler is still ~26% covered (codecov: 17 missing lines on this file). The x-axis category branch, horizontal-bar orientation, the restored axis-label guard, and the non-category branch are all untested — the same file that carried the three earlier bugs. Two of the fixes I asked for last round shipped without a regression test. Add: category-series click → filter `col === xAxis.label`; `componentType: 'xAxis'` → `onDrillDown` not called; first drill render → renderer receives `chartStatus: 'loading'`.
- **No feature flag, no `docs/`, no stated relationship to the existing `DRILL_BY` / drill-to-detail.** A second, parallel drill mechanism on a size/XXL PR is a maintainer decision before merge.
- **`EchartsTimeseries.tsx` drill handler** — the breadcrumb label uses `String(categoryAxisValue)` / `String(props.name)`, while the groupby path (`utils/eventHandlers.ts`) and the cross-filter path both use `formatSeriesName`. Timeseries breadcrumb and cross-filter labels show raw values (unformatted decimals, raw timestamps). Use `formatSeriesName` for parity. For a non-category (time) top level the raw `props.name` is also used as the filter `val`, which can fail to match the underlying column — low-probability, but worth guarding if time-as-drill-root is meant to be supported.
- **`src/components/Chart/chartReducer.ts:226`** — the "ignore a keyed action when the chart is missing" guard is an unrelated defensive change bundled into this feature PR, and it silently drops every keyed action for a missing chart. Its own comment ties it to drill-down cross-filtering — i.e. the drill flow dispatches against removed charts. Split it out and fix the root cause rather than masking the symptom.
### 🔵 Nits
- `src/components/Chart/DrillDown/DrillDownBreadcrumb.tsx` — raw `<div>/<span>/<button>`; `@superset-ui/core/components` exports `Breadcrumb`.
- BoxPlot `onDrillDown` passthrough is the only transformProps still without a hook test (Pie/Funnel/Gauge/Radar each got one).
### 🙌 Praise
- The x_axis/groupby fix is the right shape: anchor the drill to `hierarchy[currentDepth]`, advance the x-axis, keep the series `groupby`, and pin exactly that combination with a test.
<!-- enxdev-review-agent:db42179 -->
_Reviewed by EnxDev's Review Agent — @EnxDev · HEAD db42179._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4928902517
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3551970854
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:
##########
@@ -244,6 +245,61 @@ export default function EchartsTimeseries({
const eventHandlers: EventHandlers = {
click: props => {
+ // Drill-down takes priority over cross-filter when a hierarchy is configured.
+ // The DrillDownHost provides onDrillDown only when a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ if (hasDrillHierarchy) {
+ if (clickTimer.current) {
+ clearTimeout(clickTimer.current);
+ }
+ clickTimer.current = setTimeout(() => {
+ const { seriesName: name } = props;
+ const drillFilters: BinaryQueryObjectFilterClause[] = [];
+ if (hasDimensions) {
+ const values = labelMap[name] ?? [];
+ groupby.forEach((dimension, i) => {
+ drillFilters.push({
+ col: dimension,
+ op: '==',
+ val: values[i],
+ formattedVal: String(values[i]),
+ });
+ });
+ // Cross-filter is emitted by the DrillDownHost with the full
+ // accumulated drill path; here we only report the click upward.
+ } else if (
+ xAxis.type === AxisType.Category &&
+ props.data?.[0] != null
+ ) {
+ // Bar chart with x_axis only (no groupby dimensions)
+ drillFilters.push({
+ col: xAxis.label,
+ op: '==',
+ val: props.data[0],
+ formattedVal: String(props.data[0]),
+ });
Review Comment:
**Suggestion:** The category drill branch hardcodes `props.data[0]`, but for horizontal charts the category axis value is at a different index (already handled elsewhere via `getCategoryAxisValue`). This causes drill-down to filter on the measure instead of the category for horizontal bars. Reuse the orientation-aware category extraction here. [incorrect variable usage]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Horizontal bar drill-down filters by metric, not category.
- ⚠️ Dashboard cross-filters inconsistent for horizontal bar charts.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Create a timeseries chart with no groupby (hasDimensions is false in
EchartsTimeseries.tsx:102) using a Bar series type configured with orientation ===
OrientationType.Horizontal in formData (transformProps sets isHorizontal from orientation
at superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts:102 and
builds horizontal bar series accordingly).
2. Ensure the X-axis is categorical so xAxis.type === AxisType.Category in
EchartsTimeseries
(superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:223-224)
and configure a drilldown_hierarchy so DrillDownHost provides onDrillDown
(DrillDownHost.tsx:38-71) to the chart renderer and down to EchartsTimeseries (props
onDrillDown at EchartsTimeseries.tsx:64).
3. In this horizontal bar configuration, ECharts data points are shaped such that the
category label is at index 1; EchartsTimeseries already accounts for this via
categoryAxisValueIndex = formData.orientation === OrientationType.Horizontal ? 1 : 0 and
getCategoryAxisValue(data, name) using data[categoryAxisValueIndex]
(EchartsTimeseries.tsx:225-235), which is reused by cross-filter logic elsewhere (click
cross-filter at 318-323 and context menu cross-filter at 410-415).
4. When you left-click a bar with a drilldown hierarchy active, the click handler’s
drill-down branch for category axes (EchartsTimeseries.tsx:271-281) fires and builds
drillFilters with val: props.data[0]; for horizontal bars props.data[0] holds the metric
value, not the category, so onDrillDown receives filters scoped to the measure rather than
the category, and DrillDownHost (DrillDownHost.tsx:42-63) emits a cross-filter that
mis-scopes the drilled chart and other dashboard charts.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=8de288e52932447182cd718e7e472c74&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=8de288e52932447182cd718e7e472c74&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
**Line:** 271:281
**Comment:**
*Incorrect Variable Usage: The category drill branch hardcodes `props.data[0]`, but for horizontal charts the category axis value is at a different index (already handled elsewhere via `getCategoryAxisValue`). This causes drill-down to filter on the measure instead of the category for horizontal bars. Reuse the orientation-aware category extraction here.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=f90f4438f352d1802fa28f5a180dc2efd294c5fbcf16a8ed887e1fd221891456&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=f90f4438f352d1802fa28f5a180dc2efd294c5fbcf16a8ed887e1fd221891456&reaction=dislike'>👎</a>
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:
##########
@@ -244,6 +245,61 @@ export default function EchartsTimeseries({
const eventHandlers: EventHandlers = {
click: props => {
+ // Drill-down takes priority over cross-filter when a hierarchy is configured.
+ // The DrillDownHost provides onDrillDown only when a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ if (hasDrillHierarchy) {
+ if (clickTimer.current) {
+ clearTimeout(clickTimer.current);
+ }
+ clickTimer.current = setTimeout(() => {
+ const { seriesName: name } = props;
+ const drillFilters: BinaryQueryObjectFilterClause[] = [];
+ if (hasDimensions) {
+ const values = labelMap[name] ?? [];
+ groupby.forEach((dimension, i) => {
+ drillFilters.push({
+ col: dimension,
+ op: '==',
+ val: values[i],
+ formattedVal: String(values[i]),
+ });
+ });
Review Comment:
**Suggestion:** The drill filter mapping uses `values[i]` directly, but timeseries `labelMap` entries can include metric values before dimension values (the same file already compensates with a metrics offset in cross-filter logic). On multi-metric grouped charts this will drill on the wrong values and produce incorrect filters. Compute the dimension index with the metrics offset before reading from `values`. [logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ Multi-metric timeseries drill-down filters on wrong values.
- ⚠️ Cross-filtered sibling charts show inconsistent scoped data.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Configure a mixed timeseries chart with multiple metrics and at least one groupby
dimension, then enable a drilldown hierarchy in the chart formData so DrillDownHost wires
onDrillDown (superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:38-71)
into the plugin.
2. The Timeseries transform builds labelMap from the backend label_map for each series
(superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts:79-92),
where entries contain metric values followed by dimension values; cross-filter logic
elsewhere already compensates by computing metricsCount = v.length - groupby.length and
indexing dimensions at metricsCount + idx (getCrossFilterDataMask at
EchartsTimeseries.tsx:135-139, and drillByFilters in the context menu at
EchartsTimeseries.tsx:382-388).
3. On the dashboard, left-click a multi-metric grouped series to drill; the click handler
in EchartsTimeseries
(superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:246-299)
sees hasDrillHierarchy and hasDimensions, reads const values = labelMap[name] ?? [] and
then for each groupby dimension uses val: values[i] (lines 260-265), which for
multi-metric series resolves to metric or time-offset entries instead of the actual
dimension values.
4. The resulting drillFilters array is passed to onDrillDown
(EchartsTimeseries.tsx:293-297), and DrillDownHost converts each into pathFilters and
emits a cross-filter (DrillDownHost.tsx:42-63) based on these wrong values, causing the
drilled chart and other dashboard charts to be filtered on metric/time-offset tokens
rather than the intended dimension values.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=8a7a00f340cb4f5e877552cc1c4870a9&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=8a7a00f340cb4f5e877552cc1c4870a9&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
**Line:** 260:268
**Comment:**
*Logic Error: The drill filter mapping uses `values[i]` directly, but timeseries `labelMap` entries can include metric values before dimension values (the same file already compensates with a metrics offset in cross-filter logic). On multi-metric grouped charts this will drill on the wrong values and produce incorrect filters. Compute the dimension index with the metrics offset before reading from `values`.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=c5d5754cf522c57e23f44ebc2992c7030d0ee5f2114a5955e65a9aa45077b84d&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=c5d5754cf522c57e23f44ebc2992c7030d0ee5f2114a5955e65a9aa45077b84d&reaction=dislike'>👎</a>
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:
##########
@@ -244,6 +245,61 @@ export default function EchartsTimeseries({
const eventHandlers: EventHandlers = {
click: props => {
+ // Drill-down takes priority over cross-filter when a hierarchy is configured.
+ // The DrillDownHost provides onDrillDown only when a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ if (hasDrillHierarchy) {
+ if (clickTimer.current) {
+ clearTimeout(clickTimer.current);
+ }
+ clickTimer.current = setTimeout(() => {
+ const { seriesName: name } = props;
+ const drillFilters: BinaryQueryObjectFilterClause[] = [];
+ if (hasDimensions) {
+ const values = labelMap[name] ?? [];
+ groupby.forEach((dimension, i) => {
+ drillFilters.push({
+ col: dimension,
+ op: '==',
+ val: values[i],
+ formattedVal: String(values[i]),
+ });
+ });
+ // Cross-filter is emitted by the DrillDownHost with the full
+ // accumulated drill path; here we only report the click upward.
+ } else if (
+ xAxis.type === AxisType.Category &&
+ props.data?.[0] != null
+ ) {
+ // Bar chart with x_axis only (no groupby dimensions)
+ drillFilters.push({
+ col: xAxis.label,
+ op: '==',
+ val: props.data[0],
+ formattedVal: String(props.data[0]),
+ });
+ // Cross-filter is emitted by the DrillDownHost with the full
+ // accumulated drill path; here we only report the click upward.
+ } else if (props.name) {
+ // Fallback: use the event name (typically the x-axis label)
+ drillFilters.push({
+ col: xAxis.label,
+ op: '==',
+ val: props.name,
+ formattedVal: String(props.name),
+ });
Review Comment:
**Suggestion:** The fallback condition checks `props.name` by truthiness, so valid category names like `0` are treated as absent and no drill filter is emitted. Use a null/undefined check instead of a truthy check so zero-like labels still drill correctly. [falsy zero check]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ Drill-down ignores clicks on zero-valued categories.
- ⚠️ Dashboard cross-filters inconsistent for numeric-zero labels.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Configure a timeseries-based ECharts chart (Bar, Line, etc.) such that the clicked
element’s category or label is the numeric value 0 (for example, a dimension containing
values 0, 1, 2) and enable a drilldown hierarchy so DrillDownHost wires onDrillDown into
the chart (DrillDownHost.tsx:38-71).
2. Ensure the click lands in the drill-down branch where the category-axis shortcut does
not apply — e.g., either xAxis.type is not AxisType.Category or props.data is unset for
the event — so the click handler in EchartsTimeseries falls through to the fallback
condition
(superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:284-291).
3. For the data point whose label is 0, ECharts supplies props.name === 0; the fallback
branch checks else if (props.name) (line 284), which uses a truthiness check, so 0 is
treated as falsy and the drillFilters push logic is skipped entirely, leaving drillFilters
empty for this valid label.
4. Because drillFilters.length === 0, the handler does not call onDrillDown (the call at
EchartsTimeseries.tsx:293-297 is guarded), meaning DrillDownHost never sees the drill
event for label 0, the breadcrumb stack is not updated, and no cross-filters are emitted,
so clicks on zero-valued categories silently fail while other labels drill correctly.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=19d6e423485e463ba6b70b8aeb1ada72&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=19d6e423485e463ba6b70b8aeb1ada72&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
**Line:** 284:291
**Comment:**
*Falsy Zero Check: The fallback condition checks `props.name` by truthiness, so valid category names like `0` are treated as absent and no drill filter is emitted. Use a null/undefined check instead of a truthy check so zero-like labels still drill correctly.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=43723b7a75c73317f73c89ac83c7cdf65377eaea78b554a31938e13d4cb0e5fd&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=43723b7a75c73317f73c89ac83c7cdf65377eaea78b554a31938e13d4cb0e5fd&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4935525288
### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
| Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | e342acd2542abc6f8c099564066f5b5153536dbf |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/6a50ebc9b586c10008f14991 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-41907--superset-docs-preview.netlify.app](https://deploy-preview-41907--superset-docs-preview.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br /><br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">🤖</span> Make changes | [Run an agent on this branch](https://app.netlify.com/projects/superset-docs-preview/agent-runs#DcpBCoAgEADAv-y5SCFD-0HPWNcNBUvZhIjo73mbw7xQpRy1wQowgBc8KXbvjG1iiijtGoOknEO5zzEmFhSKT7-Bay7PFvpe0Cj25LyxC2mllN317JyG7wc) |
---
<!-- [superset-docs-preview Preview](https://deploy-preview-41907--superset-docs-preview.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4971994098
I added a hold label to this PR because I believe it's a significant enough change to require a SIP. I think we want to look at the need for an additional drilling method since we already have Drill By that allows a lot of flexibility. So to switch to something hierarchical that require configuration should get some level of consensus. I think we'd want to see the UI in screenshots of how the drilling works. I think we'd want to hear the use cases of why we need a new drilling method or what's insufficient about the existing one, and I think we'll need more detail on how one sets up the hierarchy for hierarchical drilling, especially in terms of semantic layer extensions that are now landing.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5528447549
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-6142659040
@tomerkl65 checking back in, and thanks for getting the DISCUSS thread out on dev@. That was the piece I was waiting on, so I'll keep an eye on it. I'm leaving the `hold:sip!` label until it has run its course and the vote is done.
While that plays out, there are a couple of things you can do that don't depend on the vote:
- The branch has conflicts with master now (mostly around `Chart.tsx` and `ChartRenderer.tsx`), so it'll need a rebase at some point.
- There are still a handful of open threads from @sadpandajoe's last round that look like real fixes rather than nits: Custom SQL (AdhocColumn) handling in the hierarchy control and `useDrillDownState`, gating the whole section on `DRILL_DOWN` instead of just the control, and the failed-drill path keeping stale state. Those would be good to settle before anyone does a final pass.
No rush on my side, since nothing merges until the SIP is settled anyway. Holler if anything is unclear or you want a second pair of eyes on the thread, or want a hand with the SIP process.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3559090590
##########
superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts:
##########
@@ -164,16 +164,52 @@ export const allEventHandlers = (
selectedValues,
coltypeMapping,
formData,
+ onDrillDown,
} = transformedProps;
+
+ // When a drill-down hierarchy is configured, left-click drills instead of
+ // emitting a cross-filter. The DrillDownHost provides onDrillDown only when
+ // a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ const drillDownClickHandler =
+ hasDrillHierarchy && groupby.length > 0
+ ? (e: { name: string }) => {
+ const values = labelMap[e.name];
+ if (!values) return;
+ const drillFilters: BinaryQueryObjectFilterClause[] = [];
+ groupby.forEach((dimension, i) => {
+ drillFilters.push({
+ col: dimension,
+ op: '==',
+ val: values[i],
+ formattedVal: formatSeriesName(values[i], {
+ timeFormatter: getTimeFormatter(formData.dateFormat),
+ numberFormatter: getNumberFormatter(formData.numberFormat),
+ coltype: coltypeMapping?.[getColumnLabel(dimension)],
+ }),
+ });
+ });
Review Comment:
**Suggestion:** The drill-down click path builds filter values with `values[i]`, but `labelMap` entries can include leading metric/series fields (the regular cross-filter path already compensates for this with an offset). This can bind each `groupby` column to the wrong value and emit incorrect drill filters. Reuse the same offset logic used in `getCrossFilterDataMask` (or a shared helper) when mapping `groupby` dimensions to `labelMap` values. [incorrect variable usage]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
❌ Drill-down filters bind dimensions to incorrect values.
⚠️ Timeseries drill-down cross-filters other charts inaccurately.
⚠️ Dashboard drill-down exploration yields confusing, inconsistent results.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Run a multi-metric ECharts timeseries chart with at least one groupby dimension so the
backend builds a composite label_map entry (see
`superset/common/query_context_processor.py:149-167` where `label_map` values come from
splitting flattened column labels that include metrics first, then groupby columns).
2. On the frontend, observe that
`superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts:280-288`
converts `label_map` into `labelMap`, preserving the array order from the backend so
`labelMap[seriesKey]` still starts with non-groupby (metric/time-compare) tokens followed
by groupby values.
3. When drill-down hooks are wired, `Timeseries/transformProps.ts:1236-1243` passes
`labelMap`, `groupby`, `selectedValues`, and `onDrillDown` into the chart props, and the
chart uses `allEventHandlers` from
`superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts:155-103` to
create the click handler.
4. In `getCrossFilterDataMask` at `eventHandlers.ts:42-51`, the regular cross-filter path
explicitly compensates for prefixed non-groupby fields by computing `metricsCount =
v.length - groupby.length` and indexing `v[metricsCount + idx]`, proving that `labelMap`
entries may have leading metric/series fields that must be skipped when mapping to
`groupby`.
5. However, the drill-down click handler added in this PR at `eventHandlers.ts:56-73` uses
`const values = labelMap[e.name];` and then `groupby.forEach((dimension, i) => { val:
values[i], ... })` (lines 181-192 in the PR hunk) without applying the `metricsCount`
offset, so for composite labels `values[0]` is a metric/series token rather than the first
groupby value.
6. Add a drill-down hierarchy to the timeseries chart and click a series point whose
`labelMap` entry includes a leading metric/series token; the handler will construct
`drillFilters` with each `groupby` column bound to `values[i]` (wrongly including
metric/series tokens), and `onDrillDown` will receive misaligned filters and label,
causing the DrillDownHost to emit an incorrect cross-filter path for other dashboard
charts.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=c22205ec869b4d6ebcf4507575f7f092&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=c22205ec869b4d6ebcf4507575f7f092&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/utils/eventHandlers.ts
**Line:** 181:192
**Comment:**
*Incorrect Variable Usage: The drill-down click path builds filter values with `values[i]`, but `labelMap` entries can include leading metric/series fields (the regular cross-filter path already compensates for this with an offset). This can bind each `groupby` column to the wrong value and emit incorrect drill filters. Reuse the same offset logic used in `getCrossFilterDataMask` (or a shared helper) when mapping `groupby` dimensions to `labelMap` values.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=7bd984302c80ef9f9eca1fd3c4d7ea4eee3d953c8d30b6b5c9923bbf487d178d&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=7bd984302c80ef9f9eca1fd3c4d7ea4eee3d953c8d30b6b5c9923bbf487d178d&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3573623705
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:
##########
@@ -221,29 +232,50 @@ export default function EchartsTimeseries({
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
- const categoryAxisValueIndex =
- formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
- (data: unknown, name: unknown) => {
- if (Array.isArray(data)) {
- const categoryAxisValue = data[categoryAxisValueIndex];
- if (
- typeof categoryAxisValue === 'string' ||
- typeof categoryAxisValue === 'number'
- ) {
- return categoryAxisValue;
- }
- }
- if (typeof name === 'string' || typeof name === 'number') {
- return name;
- }
- return undefined;
- },
- [categoryAxisValueIndex],
+ (data: unknown, name: unknown) =>
+ resolveCategoryAxisValue(data, name, formData.orientation),
+ [formData.orientation],
);
const eventHandlers: EventHandlers = {
click: props => {
+ // Drill-down takes priority over cross-filter when a hierarchy is configured.
+ // The DrillDownHost provides onDrillDown only when a hierarchy exists.
+ const hasDrillHierarchy = !!onDrillDown;
+
+ if (hasDrillHierarchy) {
+ if (clickTimer.current) {
+ clearTimeout(clickTimer.current);
+ }
+ clickTimer.current = setTimeout(() => {
+ // Timeseries-family charts are always x-axis driven: the hierarchy
+ // advances along the x-axis column, and any groupby is only a series
+ // breakdown. So drill on the clicked x-axis value, never the series
+ // dimension. Non-series clicks (e.g. axis labels) yield no filters.
+ const drillFilters = buildTimeseriesDrillFilters({
+ componentType: props.componentType,
+ data: props.data,
+ name: props.name,
+ xAxisType: xAxis.type,
+ xAxisLabel: xAxis.label,
+ orientation: formData.orientation,
+ dateFormat: formData.dateFormat,
+ numberFormat: formData.numberFormat,
+ coltype: coltypeMapping?.[xAxis.label],
+ });
Review Comment:
**Suggestion:** For time-axis clicks, this forwards `props.name` into drill filter construction, but ECharts click `name` is often the formatted axis label while the raw filterable value is in the data tuple (the same file already uses `data[0]` for time drill-to-detail). This can generate drill filters with display strings instead of raw temporal values and return empty/incorrect drilled results. Use the raw axis value from the clicked data point for non-category axes when building drill filters. [logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ Timeseries drill-down on time axes returns empty results.
- ❌ Dashboard cross-filters from drill path mis-scope linked charts.
- ⚠️ Breadcrumb labels no longer reflect actual filtered timestamps.
- ⚠️ Users experience inconsistent behavior between context menu drill modes.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Render a Timeseries chart with a time x-axis on a dashboard so that the React component
`EchartsTimeseries` in
`superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:48-69`
is used with `xAxis.type === AxisType.Time` and `onDrillDown` provided by the
DrillDownHost (drill-down hierarchy configured in the chart controls).
2. Click a data point in the timeseries; the ECharts click event is handled by
`eventHandlers.click` defined in `EchartsTimeseries.tsx:241-264`, which, when
`hasDrillHierarchy` is true, calls `buildTimeseriesDrillFilters` with `data: props.data`
and `name: props.name` (see lines 256-266 where the drill filter params are constructed).
3. In
`superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/drillFilters.ts:85-135`,
`buildTimeseriesDrillFilters` handles non-category axes (time axis) via the branch
starting at line 122: it ignores `data` and builds the filter from `name` (`val: name as
string | number`, `formattedVal: format(name as string | number)` on lines 124-131), while
the same Timeseries file uses `data[0]` as the raw timestamp for drill-to-detail in the
context menu handler (`EchartsTimeseries.tsx:75-86` where `val: data[0]` and
`formattedVal: xValueFormatter(data[0])` are used when `xAxis.type === AxisType.Time`).
4. On time axes, the ECharts click event typically carries the raw timestamp in the series
data tuple (`props.data[0]`) and a formatted axis label in `props.name`; because
`buildTimeseriesDrillFilters` uses `name` instead of the raw axis value, the emitted drill
filters (filtering `col: xAxisLabel` by `val: name`) can contain display strings rather
than the underlying temporal values, causing the backend drill-down query to be scoped to
mismatched values and returning empty or incorrect results, while the drill-to-detail
context menu (which uses `data[0]`) still works as expected—demonstrating the
inconsistency.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=579d64ea322d4be4b2af8967c7cadb1d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=579d64ea322d4be4b2af8967c7cadb1d&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
**Line:** 256:266
**Comment:**
*Logic Error: For time-axis clicks, this forwards `props.name` into drill filter construction, but ECharts click `name` is often the formatted axis label while the raw filterable value is in the data tuple (the same file already uses `data[0]` for time drill-to-detail). This can generate drill filters with display strings instead of raw temporal values and return empty/incorrect drilled results. Use the raw axis value from the clicked data point for non-category axes when building drill filters.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=ba307b8ce3177bb3197883e1b40ab2e30818e05baa933a986823faa47d683a2c&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=ba307b8ce3177bb3197883e1b40ab2e30818e05baa933a986823faa47d683a2c&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4927092522
## [Codecov](https://app.codecov.io/gh/apache/superset/pull/41907?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:x: Patch coverage is `86.53199%` with `40 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 64.64%. Comparing base ([`3dd5e69`](https://app.codecov.io/gh/apache/superset/commit/3dd5e69714fe295ab979b9d58071ed7b640053c0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`d77f08d`](https://app.codecov.io/gh/apache/superset/commit/d77f08d16f2730d3f4a3634658e0b2c0d01a5a9d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
| [Files with missing lines](https://app.codecov.io/gh/apache/superset/pull/41907?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
|---|---|---|
| [...chart-echarts/src/Timeseries/EchartsTimeseries.tsx](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&filepath=superset-frontend%2Fplugins%2Fplugin-chart-echarts%2Fsrc%2FTimeseries%2FEchartsTimeseries.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvcGx1Z2lucy9wbHVnaW4tY2hhcnQtZWNoYXJ0cy9zcmMvVGltZXNlcmllcy9FY2hhcnRzVGltZXNlcmllcy50c3g=) | 26.08% | [17 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...rc/components/Chart/DrillDown/useDrillDownState.ts](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fcomponents%2FChart%2FDrillDown%2FuseDrillDownState.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL2NvbXBvbmVudHMvQ2hhcnQvRHJpbGxEb3duL3VzZURyaWxsRG93blN0YXRlLnRz) | 92.65% | [13 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...d/src/components/Chart/DrillDown/DrillDownHost.tsx](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fcomponents%2FChart%2FDrillDown%2FDrillDownHost.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL2NvbXBvbmVudHMvQ2hhcnQvRHJpbGxEb3duL0RyaWxsRG93bkhvc3QudHN4) | 83.33% | [9 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...ns/plugin-chart-echarts/src/utils/eventHandlers.ts](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&filepath=superset-frontend%2Fplugins%2Fplugin-chart-echarts%2Fsrc%2Futils%2FeventHandlers.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvcGx1Z2lucy9wbHVnaW4tY2hhcnQtZWNoYXJ0cy9zcmMvdXRpbHMvZXZlbnRIYW5kbGVycy50cw==) | 93.33% | [1 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/41907?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## master #41907 +/- ##
==========================================
+ Coverage 64.60% 64.64% +0.04%
==========================================
Files 2712 2716 +4
Lines 151168 151458 +290
Branches 34769 34865 +96
==========================================
+ Hits 97658 97908 +250
- Misses 51684 51724 +40
Partials 1826 1826
```
| [Flag](https://app.codecov.io/gh/apache/superset/pull/41907/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [javascript](https://app.codecov.io/gh/apache/superset/pull/41907/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `69.83% <86.53%> (+0.06%)` | :arrow_up: |
Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>
[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/superset/pull/41907?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4925733575
## EnxDev's Review Agent — apache/superset#41907 · HEAD 6f70126
**request changes** — the drill path is wrong for x-axis charts that also have a Dimension: it drills on the series dimension, not the x-axis column the breadcrumb claims.
Context: reviewed against the diff only (no linked issue/SIP). The three earlier bot findings on `EchartsTimeseries.tsx` (metrics offset, `getCategoryAxisValue`, `props.name != null`) are genuinely fixed in this revision — I verified each against the code, not the claim — but none of them has a regression test.
### 🔴 Functional
- **`plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:258`** · _High_ — `hasDimensions` is `groupby.length > 0`, and Bar/Line/Area/Scatter/Step have **both** `x_axis` and `groupby` (Dimensions). With `x_axis: country`, `groupby: [gender]`, `drilldown_hierarchy: [region, city]` → `hierarchy = [country, region, city]`, but the click builds filters from `groupby` (`gender`), so it drills on the series dimension. `useDrillDownState.ts:266` then takes the `hasGroupby` branch and swaps `groupby → [region]` while `x_axis` stays `country`. Result: the chart never advances along the hierarchy, the breadcrumb reads `country › M`, and the emitted cross-filter scopes other charts by `gender`. Anchor the drill to `hierarchy[currentDepth]` — when `x_axis` is set, read the category via `getCategoryAxisValue` and swap `x_axis`, not `groupby`. **regression test:** Bar chart with `x_axis: 'country'` + `groupby: ['gender']`, hierarchy `['country','region']`; click a bar �
�� expect the filter col to be `country` and `effectiveFormData.x_axis === 'region'`.
- **`src/components/Chart/DrillDown/DrillDownHost.tsx:139`** · _Medium_ — `chartStatus: isLoading ? 'loading' : 'rendered'`, but on the render right after `drillDown()` the fetch effect hasn't run: `isLoading === false` and `drillData === null`. `ChartRenderer` gets `queriesResponse: null` + `chartStatus: 'rendered'` → `resultsReady` false → `queriesData` undefined → `SuperChart` paints the "No results were returned for this query" empty state for a frame, then blanks while loading. Every drill click flashes "No results". Fix: `chartStatus: isLoading || effectiveQueriesResponse == null ? 'loading' : 'rendered'`. **regression test:** call `onDrillDown`, assert the renderer receives `chartStatus: 'loading'` before the fetch resolves.
- **`plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx:276`** · _Medium_ — the category branch checks only `xAxis.type === AxisType.Category` and drops master's `props.componentType === 'series'` guard (kept on line 260 of the cross-filter path). ECharts fires `click` for axis labels too, so clicking an x-axis tick now drills. Restore the guard. **regression test:** click event with `componentType: 'xAxis'` → `onDrillDown` not called.
- **`src/components/Chart/DrillDown/useDrillDownState.ts:422`** · _Low_ — when the author lists exactly the chart's own dimension (`drilldown_hierarchy: ['country']` with `x_axis: 'country'`), `hierarchy.length === 1` and `drillStack.length >= hierarchy.length - 1` is true on the first click, so it takes the leaf branch and never pushes. `isDrilling` stays false → `overlayProps` is `{}` → the chart never filters. But `onDrillDown` is still provided, so normal cross-filter click behavior is replaced and the emitted cross-filter can't be toggled off. The control's own docstring invites this ("prepended automatically if it is not already listed"). Gate `hasHierarchy` on `hierarchy.length >= 2`. **regression test:** single-entry hierarchy equal to `x_axis` → `hasHierarchy === false`.
### 🟡 Should-fix
- **`src/components/Chart/Chart.tsx:359`** — `ChartContainer` feeds `Chart.tsx` from **both** the dashboard and Explore (`ExploreChartPanel`), so drill-down and its click-behavior override are live in Explore, where the PR says this is a dashboard feature. The control panel and the rendered chart then disagree, and `configKey` is only `slice_id__viz_type`, so editing controls doesn't reset the drill. Gate on `source === ChartSource.Dashboard`.
- **`src/components/Chart/DrillDown/useDrillDownState.ts:311`** — `JSON.stringify(effectiveFormData)` runs on every render for **every chart in the app**, hierarchy or not (at depth 0 `effectiveFormData === formData`). Guard with `hasHierarchy && currentDepth > 0`.
- **`src/components/Chart/DrillDown/useDrillDownState.ts:73`** — the module-level `drillStateStore` is keyed by `slice_id`, never evicted, and shared across dashboards and Explore for the same slice. Key by `chartId`, or scope it to the dashboard id.
- **`src/components/Chart/DrillDown/useDrillDownState.ts:218`** — `ensureIsArray(...) as string[]` is an unchecked cast; the control spreads `sharedControls.groupby` (`dndGroupByControl`, `freeForm: true`), so entries can be `AdhocColumn` objects. Those never match `drillLevels.includes(xAxisStr)`, get assigned raw into `x_axis`/`groupby`, and when one lands at `hierarchy[0]` the breadcrumb renders an object as a React child. Set `freeForm: false` on the control or normalize with `getColumnLabel`.
- **`packages/superset-ui-chart-controls/src/sections/drilldownHierarchy.tsx:61`** — `renderTrigger: false` contradicts its own comment ("no re-render/re-query is needed"). With `false`, every hierarchy edit marks the chart stale and re-runs the base query. Use `renderTrigger: true`.
- **`src/components/Chart/DrillDown/DrillDownHost.tsx:149`** — `handleResetTo` dispatches `updateDataMask` regardless of `emitCrossFilters`, while `onDrillDown` gates on it. With cross-filters off, drilling emits nothing but returning to root writes an empty `filterState` and forces `triggerQuery(true, chartId)` — a full re-query — even though nothing was ever emitted.
- **`src/components/Chart/chartReducer.ts:230`** — an unrelated defensive fix bundled into a feature PR, and it silently drops every keyed action for a missing chart. The comment ties it to drill-down cross-filtering, which suggests the drill flow dispatches against removed charts. Split it out and fix the root cause rather than the symptom.
- **Test coverage** — `EchartsTimeseries.tsx` gained 67 lines of drill logic with zero tests; `eventHandlers.test.ts` only exercises the groupby path in `utils/eventHandlers.ts`. The x-axis branch, horizontal orientation, zero-valued labels, and the multi-metric offset are all untested — exactly the three bugs already reported once on this file. The `x_axis` + `groupby` case above would have been caught by any test of that combination.
- **No feature flag, no docs.** Superset already ships `DRILL_BY` (default on) and drill-to-detail. A second, parallel drill mechanism with no flag, no `docs/` update, and no stated relationship to `DrillBy` needs a maintainer decision before merge.
- **`src/components/Chart/DrillDown/DrillDownBreadcrumb.tsx:94`** — raw `<div>`/`<span>`/`<button>` where `@superset-ui/core/components` exports `Breadcrumb`. `DrillDownHost.test.tsx` also imports `render` from `@testing-library/react` instead of `spec/helpers/testing-library`, and uses `as any` four times.
### 🔵 Nits
- `src/components/Chart/DrillDown/useDrillDownState.ts:388` — `console.error` logs the full `effectiveFormData` on every failed drill.
- `src/components/Chart/ChartRenderer.tsx:149,181` — inline `import('@superset-ui/core').BinaryQueryObjectFilterClause`; use a top-level type import.
- `src/components/Chart/DrillDown/DrillDownHost.tsx:191` — the breadcrumb-height effect depends only on `drillStack.length`, so it misses `selectedLeaf`/`error` changes and wrapping, leaving `adjustedHeight` stale.
- `src/components/Chart/DrillDown/DrillDownBreadcrumb.tsx:100` — `key={index}`.
- `src/components/Chart/DrillDown/DrillDownBreadcrumb.test.tsx` — the last test's comment ("only have been called for the root click test") describes state from another, isolated test.
### 🙌 Praise
- `src/components/Chart/DrillDown/useDrillDownState.ts:302` — the `effectiveFormDataRef` + serialized-key guard against identity-only `formData` churn is a subtle bug to have found, and the comment names the exact failure mode (in-flight request cancelled, chart spinning to the 60s timeout).
- `src/components/Chart/DrillDown/useDrillDownState.ts:73` — persisting drill state synchronously on mutate rather than in an effect, with a remount test to lock it in.
<!-- enxdev-review-agent:6f70126 -->
_Reviewed by EnxDev's Review Agent — @EnxDev · HEAD 6f70126._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3558252164
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.test.tsx:
##########
@@ -0,0 +1,282 @@
+/**
+ * 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 { ReactElement } from 'react';
+import {
+ render as rtlRender,
+ screen,
+ act,
+ fireEvent,
+} from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ThemeProvider, supersetTheme } from '@apache-superset/core/theme';
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Inconsistent test utility imports</b></div>
<div id="fix">
Use `spec/helpers/testing-library` (the project's standard test utility) instead of raw `@testing-library/react` with a manual ThemeProvider wrapper. This aligns with `DrillDownBreadcrumb.test.tsx` which already uses the standardized import at line 19.
</div>
</div>
<small><i>Code Review Run #647224</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3966752876
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,497 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} 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[];
+}
+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;
+}
+
+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,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = chartId;
+
+ // 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) and via clearDrillDownState.
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
Review Comment:
**[P1] Scope persisted drill state to the dashboard data mask and configuration.** This map survives every unmount and is keyed only by `chartId`; on dashboards that is the saved slice id (`gridComponents/Chart` passes `props.id`). Dashboard unmount clears Redux data masks, and users can also remove this chart cross-filter from the filter bar, but neither path clears this store. A remount then restores the old drill stack and fires a drilled query while linked charts are back at root. `prevConfigKeyRef` cannot catch a different configuration on remount because it initializes to the new key. Could we include dashboard/config identity in the stored record and reset it when the owning data mask is cleared? A drill → unmount/clear → remount test with the same chart id would pin this.
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,497 @@
+/**
+ * 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 {
+ getChartDataRequest,
+ handleChartDataResponse,
+} 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[];
+}
+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;
+}
+
+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,
+}: UseDrillDownStateArgs): UseDrillDownStateResult {
+ const chartKey = chartId;
+
+ // 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) and via clearDrillDownState.
+
+ const [drillStack, setDrillStack] = useState<DrillDownLevel[]>(
+ () =>
+ (chartKey != null
+ ? drillStateStore.get(chartKey)?.drillStack
+ : undefined) ?? [],
+ );
+ const [selectedLeaf, setSelectedLeaf] = useState<string | undefined>(() =>
+ chartKey != null ? drillStateStore.get(chartKey)?.selectedLeaf : undefined,
+ );
+ // 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
+ >(() =>
+ chartKey != null
+ ? drillStateStore.get(chartKey)?.selectedLeafFilters
+ : undefined,
+ );
+ const [drillData, setDrillData] = useState<QueryData[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | undefined>();
+
+ // 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,
+ });
+ }
+ },
+ [chartKey],
+ );
+
+ // 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 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],
+ ]);
+ 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;
+ 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 { response, json } = await getChartDataRequest({
+ formData: activeFormData,
+ });
+ // eslint-disable-next-line no-await-in-loop
+ const queriesResponse = await handleChartDataResponse(response, json);
Review Comment:
**[P0] Move the drill fetch onto the resolved chart-data API.** The failing `lint-frontend` job is `TS2554` on this call: `master` changed `handleChartDataResponse` to require a refetch callback (and an optional abort signal). Adding a dummy third argument would compile, but it would leave drill requests outside the new async/cancellation path. Could we call `requestChartDataResolved({ formData: activeFormData }, controller.signal)` here and abort in the effect cleanup, matching `exploreJSON`? That should restore `tsc` and keep drill-down working when `GLOBAL_ASYNC_QUERIES` is enabled.
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownBreadcrumb.tsx:
##########
@@ -0,0 +1,105 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { ReactNode } from 'react';
+import { css, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Breadcrumb } from '@superset-ui/core/components';
+import { DrillDownLevel } from './types';
+
+interface DrillDownBreadcrumbProps {
+ /** Ordered list of column names from the chart's drill-down hierarchy */
+ hierarchy: string[];
+ /** The drill levels the user has navigated through */
+ drillStack: DrillDownLevel[];
+ /** Value selected at the deepest level (shown but not clickable) */
+ selectedLeaf?: string;
+ /** Reset the drill-down to the given depth (0 = back to the start) */
+ onJumpTo: (depth: number) => void;
+}
+
+/**
+ * Compact breadcrumb shown above the chart while the user is drilled into
+ * a hierarchy. Clicking any segment jumps back up the stack.
+ *
+ * country › USA › region › Texas
+ */
+export function DrillDownBreadcrumb({
+ hierarchy,
+ drillStack,
+ selectedLeaf,
+ onJumpTo,
+}: DrillDownBreadcrumbProps) {
+ const theme = useTheme();
+
+ if (drillStack.length === 0 && !selectedLeaf) {
+ return null;
+ }
+
+ const linkCss = css`
+ cursor: pointer;
+ color: ${theme.colorPrimary};
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ &:hover {
+ text-decoration: underline;
+ }
+ `;
+
+ // Render a navigable segment as a native button since antd's Breadcrumb items
+ // are otherwise inert text; a button is keyboard-operable out of the box.
+ const clickable = (label: string, depth: number): { title: ReactNode } => ({
+ title: (
+ <button type="button" onClick={() => onJumpTo(depth)} css={linkCss}>
+ {label}
+ </button>
+ ),
+ });
+
+ // Build the breadcrumb trail: the hierarchy root, every drilled level, and
+ // the picked leaf (if any). The deepest shown level (last drilled level with
+ // no leaf, or the leaf itself) is inert.
+ const items: { title: ReactNode }[] = [clickable(hierarchy[0] ?? '', 0)];
+ drillStack.forEach((level, index) => {
+ const isLast = index === drillStack.length - 1 && !selectedLeaf;
+ items.push(
+ isLast
+ ? { title: <span>{level.label}</span> }
+ : clickable(level.label, index + 1),
+ );
+ });
+ if (selectedLeaf) {
Review Comment:
**[P2] Preserve the empty leaf in the breadcrumb too.** The state path was updated to treat `""` as a valid selection, but this truthiness check still treats it as absent. Clicking an empty-string leaf applies the filter, yet the breadcrumb does not add the leaf and leaves the previous segment looking like the active level. Could we use `selectedLeaf != null` here (and render a small `(empty)` label if a blank item would be ambiguous) plus extend the new empty-leaf test through the breadcrumb?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "tomerkl65 (via GitHub)" <gi...@apache.org>.
tomerkl65 commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5340117015
Thanks for the ping, @rusackas, and sorry for the delay — I was in the middle of exams. I'll follow up shortly with screenshots and I'll write up a SIP.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r4329610858
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,372 @@
+/**
+ * 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 { Loading } from '@superset-ui/core/components';
+import { ChartSource } from 'src/types/ChartSource';
+import type { RootState } from 'src/dashboard/types';
+import { selectAsyncModeOverride } from 'src/utils/asyncMode';
+import type { RequestParams } from 'src/components/Chart/chartAction';
+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;
+
+ // Request params for drill queries, mirroring exploreJSON so superseded
+ // drills abort, hung queries time out, and the per-dashboard async override
+ // is honored. The hook adds the per-request AbortSignal.
+ const asyncModeOverride = useSelector(selectAsyncModeOverride);
+ const dashboardId = useSelector<RootState, number | undefined>(
+ state => state.dashboardInfo?.id,
+ );
+ const webserverTimeout = useSelector<RootState, number | undefined>(
+ state => state.common?.conf?.SUPERSET_WEBSERVER_TIMEOUT,
+ );
+ const drillRequestParams = useMemo<RequestParams>(() => {
+ const params: RequestParams = {};
+ if (webserverTimeout) {
+ params.timeout = webserverTimeout * 1000;
+ }
+ if (dashboardId) {
+ params.dashboard_id = dashboardId;
+ }
+ if (asyncModeOverride) {
+ params.async_mode_override = asyncModeOverride;
+ }
+ return params;
+ }, [webserverTimeout, dashboardId, asyncModeOverride]);
+
+ const {
+ isDrilling,
+ drillStack,
+ selectedLeaf,
+ hierarchy,
+ effectiveFormData,
+ effectiveQueriesResponse,
+ isLoading,
+ error,
+ hasHierarchy,
+ drillDown,
+ resetTo,
+ } = useDrillDownState({
+ chartId: rendererProps.chartId,
+ formData,
+ baseQueriesResponse: queriesResponse,
+ crossFilterCleared,
+ requestParams: drillRequestParams,
+ });
+
+ // 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;
Review Comment:
`drillEnabled` gates the click handler, but `useDrillDownState` above runs on every chart anyway, and its fetch effect only looks at stack depth. Drill a chart on a dashboard, hit Edit chart, and Explore restores the stack from `drillStateStore` (same `chartId`, same `configKey`, `crossFilterCleared` is false there) and fires the drilled query, retries included, for a result nothing renders.
Could the flag and source check go into the hook as an `enabled` arg that skips both the restore and the fetch?
##########
superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts:
##########
@@ -335,6 +335,7 @@ export default function transformProps(
groupby,
selectedValues,
onContextMenu,
+ onDrillDown,
Review Comment:
With more than one metric the boxes are named `group, metric`, but `labelMap` is keyed by `group` alone, so the drill handler finds no entry and bails. A multi-metric box plot with a hierarchy configured never drills, even though Box Plot is on the supported list.
Could we key `labelMap` by the series name the boxes actually use, or leave Box Plot out for now?
##########
superset-frontend/packages/superset-ui-chart-controls/src/sections/drilldownHierarchy.tsx:
##########
@@ -0,0 +1,76 @@
+/**
+ * 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,
Review Comment:
With `DRILL_DOWN` off (the default) this section still renders as an empty collapsible panel on all 11 charts, since only the control inside is gated.
`ControlPanelSectionConfig` takes `visibility` too and `ControlPanelsContainer` honors it, so it's a one-liner:
```suggestion
expanded: false,
visibility: () => isFeatureEnabled(FeatureFlag.DrillDown),
```
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,372 @@
+/**
+ * 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 { Loading } from '@superset-ui/core/components';
+import { ChartSource } from 'src/types/ChartSource';
+import type { RootState } from 'src/dashboard/types';
+import { selectAsyncModeOverride } from 'src/utils/asyncMode';
+import type { RequestParams } from 'src/components/Chart/chartAction';
+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;
+
+ // Request params for drill queries, mirroring exploreJSON so superseded
+ // drills abort, hung queries time out, and the per-dashboard async override
+ // is honored. The hook adds the per-request AbortSignal.
+ const asyncModeOverride = useSelector(selectAsyncModeOverride);
+ const dashboardId = useSelector<RootState, number | undefined>(
+ state => state.dashboardInfo?.id,
+ );
+ const webserverTimeout = useSelector<RootState, number | undefined>(
+ state => state.common?.conf?.SUPERSET_WEBSERVER_TIMEOUT,
+ );
+ const drillRequestParams = useMemo<RequestParams>(() => {
+ const params: RequestParams = {};
+ if (webserverTimeout) {
+ params.timeout = webserverTimeout * 1000;
+ }
+ if (dashboardId) {
+ params.dashboard_id = dashboardId;
+ }
+ if (asyncModeOverride) {
+ params.async_mode_override = asyncModeOverride;
+ }
+ return params;
+ }, [webserverTimeout, dashboardId, asyncModeOverride]);
+
+ const {
+ isDrilling,
+ drillStack,
+ selectedLeaf,
+ hierarchy,
+ effectiveFormData,
+ effectiveQueriesResponse,
+ isLoading,
+ error,
+ hasHierarchy,
+ drillDown,
+ resetTo,
+ } = useDrillDownState({
+ chartId: rendererProps.chartId,
+ formData,
+ baseQueriesResponse: queriesResponse,
+ crossFilterCleared,
+ requestParams: drillRequestParams,
+ });
+
+ // 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>(() => {
+ // Suspend drilling while a drill query is in error: the overlay has fallen
+ // back to the base chart, so a click here would append a base-level value
+ // as the next level and emit contradictory filters (e.g. country=USA plus
+ // country=Canada). The breadcrumb still allows navigating up, which
+ // re-queries and clears the error.
+ if (!drillEnabled || error != null) {
+ return undefined;
+ }
Review Comment:
Returning `undefined` here doesn't pause drilling, it flips the plugins back to their normal click handler. After a failed drill, clicking the fallback base chart emits a regular cross-filter (say `country IN [Canada]`) over the drill-path mask while the breadcrumb still reads `USA › ...`.
A no-op keeps them in drill mode and just swallows the click:
```suggestion
if (!drillEnabled) {
return undefined;
}
if (error != null) {
return () => {};
}
```
Worth a test that a click after a failed drill leaves `setDataMask` alone.
##########
superset-frontend/src/components/Chart/DrillDown/useDrillDownState.ts:
##########
@@ -0,0 +1,608 @@
+/**
+ * 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,
+ getColumnLabel,
+ getXAxisLabel,
+ isQueryFormColumn,
+ isXAxisSet,
+ QueryData,
+ QueryFormData,
+} from '@superset-ui/core';
+import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
+import {
+ requestChartDataResolved,
+ type RequestParams,
+} 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;
+ /**
+ * Base request params (timeout, dashboard_id, async_mode_override) for drill
+ * queries, mirroring exploreJSON. The hook adds the per-request AbortSignal.
+ * Without these a superseded synchronous drill keeps running, hung queries
+ * lack the normal timeout, and per-dashboard async overrides are ignored.
+ */
+ requestParams?: RequestParams;
+}
+
+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,
+ requestParams,
+}: 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([
Review Comment:
The comments say this key covers the dashboard slot, but `chartId` is the slice id, which is the same on every dashboard. With cross-filtering off `crossFilterCleared` never flips, so drilling chart 42 and then opening another dashboard that also has chart 42 lands you mid-drill there, despite the docstring saying closing the dashboard wipes the state.
Could the dashboard id (already in `drillRequestParams`) go into the key?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4961845674
## EnxDev's Review Agent — apache/superset#41907 · HEAD 60f31c0
**comment** — supersedes my prior [comment (db42179)](https://github.com/apache/superset/pull/41907#issuecomment-4952958103). Every prior should-fix is addressed: the timeseries drill handler was extracted to `drillFilters.ts` **with** a real test, the `DRILL_DOWN` flag + config + docs landed, and `formatSeriesName` label parity is in. No blocker remains. Dark-launched (flag off by default).
Verified against the code, not the "fixed" claims:
- **Timeseries drills the x-axis, not the series dimension.** The inline handler is gone; `buildTimeseriesDrillFilters` builds the filter from `xAxisLabel`, orientation-aware via `getCategoryAxisValue(data, name, orientation)`, with `name != null` so zero-like labels still drill. Covered by `test/Timeseries/drillFilters.test.ts` (category / horizontal / time / no-value / formatting).
- **Groupby path applies the metrics offset** — `eventHandlers.ts` uses `values[metricsCount + i]`, tested in `eventHandlers.test.ts` ("skips leading metric tokens").
- Feature flag in sync across `config.py`, `featureFlags.ts`, `feature-flags.json`; `exploring-data.mdx` explains the relationship to `DRILL_BY` / `DRILL_TO_DETAIL`.
- Retry exhaustion, unmount `isLoading` reset, remount persistence, and the reset-to-root re-query are all covered in `useDrillDownState.test.ts`.
The open bot findings are stale: codeant's four "logic-error" flags (metrics offset, horizontal orientation, falsy-zero, `values[i]`) reference pre-refactor line numbers and no longer apply; its "BoxPlot has no hierarchy control" is also fixed (`sections.drilldownHierarchySection` was added). The bito test-coverage flags (retry logic, unmount, chartReducer four-field assertion, fake-timer `afterEach`, Radar/Pie passthrough) are addressed. CI is green.
### 🟡 Should-fix
- **`plugins/plugin-chart-echarts/src/utils/eventHandlers.ts:~178`** · _Low_ — for a multi-metric Box Plot the series name is `` `${group}, ${metric}` `` but `labelMap` is keyed by `group` only (`BoxPlot/transformProps.ts:165-176`), so `labelMap[e.name]` is `undefined` → the drill handler returns early and, because drill mode replaces the normal cross-filter click, the click becomes a no-op. Only bites multi-metric BoxPlot + a configured hierarchy (matches the existing cross-filter limitation), but worth gating drill-down to single-metric or keying by the full series name. **regression test:** multi-metric BoxPlot drill click → `onDrillDown` not called today.
- **`src/components/Chart/chartReducer.ts:226`** — the "ignore a keyed action when the chart is missing" guard is still bundled into this feature PR and silently drops *every* keyed action for a missing chart. Carried over from my last review: its own comment ties it to the drill cross-filter flow dispatching against removed charts — split it out and fix the root cause rather than mask the symptom.
### 🔵 Nits
- `plugins/plugin-chart-echarts/src/utils/eventHandlers.ts` — `metricsCount = values.length - groupby.length` is recomputed inside the `groupby.forEach`; hoist it above the loop.
- `useDrillDownState.ts` — `console.error('[DrillDown] query failed:', …)`; Superset prefers `logging` from `@apache-superset/core/utils`.
- `DrillDownBreadcrumb.tsx` renders navigable segments as `<span role="button">` (the PR description claims a native `<button>`); functionally keyboard-operable via the keydown handler, so fine as-is.
### 🙌 Praise
- The prior "0 coverage" gap is closed: `drillFilters.ts`, the 640-line `useDrillDownState` spec (remount persistence + retry exhaustion), and the `DrillDownHost` full-path cross-filter test all guard the behavior they claim to.
<!-- enxdev-review-agent:60f31c0 -->
_Reviewed by EnxDev's Review Agent — @EnxDev · HEAD 60f31c0._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4963526185
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4934367506
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3560355178
##########
superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts:
##########
@@ -431,6 +431,7 @@ export default function transformProps(
groupby,
selectedValues,
onContextMenu,
+ onDrillDown,
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test for onDrillDown</b></div>
<div id="fix">
Line 434 includes `onDrillDown` in the returned props but there is no corresponding test in `test/Radar/transformProps.test.ts`. Compare Gauge plugin: it has a passing test that verifies `onDrillDown` is correctly passed through (test/Gauge/transformProps.test.ts:729-768). Add a test to prevent regression.
</div>
</div>
<small><i>Code Review Run #3f0632</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts:
##########
@@ -256,7 +256,7 @@ export default function transformProps(
};
}, {});
- const { setDataMask = () => {}, onContextMenu } = hooks;
+ const { setDataMask = () => {}, onContextMenu, onDrillDown } = hooks;
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test coverage for onDrillDown</b></div>
<div id="fix">
The `onDrillDown` pass-through and click wiring lack test coverage in `test/Pie/transformProps.test.ts`. Funnel (lines 86–128 of `test/Funnel/transformProps.test.ts`) already has two tests for this behavior. Without equivalent Pie tests, the drill-down path for Pie is unverified and may silently regress. This is a divergence from the established pattern for drill-down charts.
</div>
</div>
<small><i>Code Review Run #3f0632</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4937178750
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4931381215
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5368147507
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5623869281
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4938755930
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3555425754
##########
superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts:
##########
@@ -105,6 +105,7 @@ const config: ControlPanelConfig = {
],
],
},
+ sections.drilldownHierarchySection,
Review Comment:
**Suggestion:** This enables drill-down for BoxPlot even though BoxPlot click-to-filter mapping is still incompatible when multiple metrics are selected: series point names are built as "group, metric" but the drill/cross-filter label map is keyed only by group, so the drill handler cannot resolve clicked values and returns early. Because drill mode suppresses normal cross-filter clicks, user clicks become no-ops. Either block drill-down for BoxPlot unless exactly one metric is used, or update BoxPlot label-map/drill filter construction to key by the exact clickable series name. [incomplete implementation]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Box Plot drilldown clicks ignored with multiple metrics configured.
- ⚠️ Dashboard cross-filters from Box Plot fail in drill mode.
```
</details>
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Create an ECharts Box Plot chart using the plugin defined in
`superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/index.ts` (constructor at
lines 31-68) with at least one `groupby` dimension and multiple metrics configured via the
`groupby` and `metrics` controls in `src/BoxPlot/controlPanel.ts` (lines 77-79).
2. In the Box Plot control panel, use the newly added `sections.drilldownHierarchySection`
(line 108 in `src/BoxPlot/controlPanel.ts`) to configure a non-empty `drilldown_hierarchy`
so that the chart has a drilldown hierarchy; the `DrillDownHost` is verified in
`src/components/Chart/DrillDown/DrillDownHost.test.tsx` (lines 3-17 and 20-37) to supply
an `onDrillDown` callback whenever `drilldown_hierarchy` is present, matching the contract
described in `plugins/plugin-chart-echarts/src/types.ts` (lines 27-34).
3. Run the chart query and observe `transformProps` in
`plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts`: series point names are built
at lines 85-97 so that, when multiple metrics are selected, each point name becomes
``${groupbyLabel}, ${metric}``, while the `labelMap` is constructed at lines 165-175 keyed
only by the `groupbyLabel` (the group value) without the metric suffix.
4. When the chart renders, `EchartsBoxPlot` in `src/BoxPlot/EchartsBoxPlot.tsx` (lines
23-28) passes the transformed props, including `groupby`, `labelMap`, and `onDrillDown`,
into `allEventHandlers` in `plugins/plugin-chart-echarts/src/utils/eventHandlers.ts`
(lines 155-201). Because `onDrillDown` is defined and `groupby.length > 0`,
`allEventHandlers` chooses `drillDownClickHandler` (lines 175-201) as the `click` handler,
which looks up `const values = labelMap[e.name];`. For multi-metric Box Plots, `e.name` is
the full series label like `"group, metric"` while `labelMap` only has keys for `"group"`,
so `values` is undefined and the handler returns early (lines 178-179) without calling
`onDrillDown`. Since the `click` handler is `drillDownClickHandler` instead of the normal
`clickEventHandler` (lines 203-211), cross-filter clicks are suppressed in drill mode, and
user left-clicks on Box Plot points become no-ops when a drilldown hierarchy is configured
with multiple metrics.
```
</details>
[](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=8e7e9a360fb148eb92c7a1815a942ab1&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=8e7e9a360fb148eb92c7a1815a942ab1&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
*(Use Cmd/Ctrl + Click for best experience)*
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts
**Line:** 108:108
**Comment:**
*Incomplete Implementation: This enables drill-down for BoxPlot even though BoxPlot click-to-filter mapping is still incompatible when multiple metrics are selected: series point names are built as "group, metric" but the drill/cross-filter label map is keyed only by group, so the drill handler cannot resolve clicked values and returns early. Because drill mode suppresses normal cross-filter clicks, user clicks become no-ops. Either block drill-down for BoxPlot unless exactly one metric is used, or update BoxPlot label-map/drill filter construction to key by the exact clickable series name.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=d7b3cbc8c3f57793443dbd4ecf97bd7cfb3286941eba57edc2def1f6c767441f&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41907&comment_hash=d7b3cbc8c3f57793443dbd4ecf97bd7cfb3286941eba57edc2def1f6c767441f&reaction=dislike'>👎</a>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3553340051
##########
superset-frontend/packages/superset-ui-chart-controls/src/sections/drilldownHierarchy.tsx:
##########
@@ -0,0 +1,69 @@
+/**
+ * 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 { 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,
+ // Configures click behavior only and does not affect the base query,
+ // so editing it re-renders without marking the chart stale.
+ renderTrigger: true,
+ },
+ },
+ ],
+ ],
+};
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing unit test coverage</b></div>
<div id="fix">
Per BITO.md rule 11730, comprehensive unit tests are required for new features covering success paths, error scenarios, validation failures, and edge cases. The `drilldownHierarchySection` control panel section lacks test coverage.
</div>
</div>
<small><i>Code Review Run #93499c</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx:
##########
@@ -59,6 +59,7 @@ const {
const config: ControlPanelConfig = {
controlPanelSections: [
sections.echartsTimeSeriesQueryWithXAxisSort,
+ sections.drilldownHierarchySection,
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing drilldown test coverage</b></div>
<div id="fix">
The newly added `drilldownHierarchySection` lacks test coverage in `controlPanel.test.ts`. Other controls (`x_axis_time_format`, `x_axis_number_format`) have explicit tests. According to BITO.md [6262], tests should verify actual business logic, not just component rendering.
</div>
</div>
<small><i>Code Review Run #93499c</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts:
##########
@@ -741,6 +741,7 @@ export default function transformProps(
onContextMenu,
onLegendStateChanged,
onLegendScroll,
+ onDrillDown,
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test coverage for onDrillDown</b></div>
<div id="fix">
The `onDrillDown` hook is integrated into `transformProps` (line 744) and returned as a transformed prop (line 1243), following the established pattern from `Pie/transformProps.ts`. However, `EchartsTimeseries.test.tsx` has tests for `onContextMenu` but lacks coverage for `onDrillDown`. Add test cases verifying the drill-down handler receives correct filters and label.
</div>
</div>
<small><i>Code Review Run #93499c</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4932938505
### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
| Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 2c92a8c501a5bddcb882e9ce415f2ce3ae052b96 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/6a509c1dda674a0009c2eb5e |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-41907--superset-docs-preview.netlify.app](https://deploy-preview-41907--superset-docs-preview.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br /><br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">🤖</span> Make changes | [Run an agent on this branch](https://app.netlify.com/projects/superset-docs-preview/agent-runs#DcpRCoAgDIDhu-w5ySKLukHHmG5hYClLiIju3t7-H74XiuSjVFgAGvCCZ4jaG2NtOUSUehmSPSXK92nizoIS4qOWuKT8rKR6RGfn0BHhOA1orU7P3jF8Pw) |
---
<!-- [superset-docs-preview Preview](https://deploy-preview-41907--superset-docs-preview.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3952665064
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,302 @@
+/**
+ * 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,
+} from '@superset-ui/core';
+import { css } from '@apache-superset/core/theme';
+import { ChartSource } from 'src/types/ChartSource';
+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;
+
+ const {
+ isDrilling,
+ drillStack,
+ selectedLeaf,
+ hierarchy,
+ effectiveFormData,
+ effectiveQueriesResponse,
+ isLoading,
+ error,
+ hasHierarchy,
+ drillDown,
+ resetTo,
+ } = useDrillDownState({
+ chartId: rendererProps.chartId,
+ formData,
+ baseQueriesResponse: queriesResponse,
+ });
+
+ // 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 {};
+ }
+ return {
+ formData: effectiveFormData as QueryFormData,
+ queriesResponse: (effectiveQueriesResponse ?? null) as QueryData[] | null,
+ // Treat "no data yet" as loading. On the first render after a drill the
+ // fetch effect hasn't run (isLoading is still false, drillData is null);
+ // without this the renderer would briefly paint a "No results" state.
+ chartStatus:
+ isLoading || effectiveQueriesResponse == null ? 'loading' : 'rendered',
Review Comment:
After the final failed drill request, `effectiveQueriesResponse` is still null, so this keeps the chart in `loading` even though the hook has an error; ChartRenderer then renders no chart at all. Could the error case render a recoverable error/base state instead of leaving the chart body blank?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3875324239
##########
superset-frontend/src/components/Chart/DrillDown/DrillDownHost.tsx:
##########
@@ -0,0 +1,302 @@
+/**
+ * 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,
+} from '@superset-ui/core';
+import { css } from '@apache-superset/core/theme';
+import { ChartSource } from 'src/types/ChartSource';
+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)[],
+ };
+ });
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing TEMPORAL_RANGE test</b></div>
<div id="fix">
The new `TEMPORAL_RANGE` branch in `toCrossFilterClauses` has no test coverage. The existing `IS NULL` test at `DrillDownHost.test.tsx:289` validates the null branch, but a regression in the temporal pass-through (e.g. accidentally wrapping `val` in `IN [...]`) would silently break temporal cross-filter scoping. Add a parallel test that drills with a `TEMPORAL_RANGE` clause and asserts the emitted clause preserves `op` and `val`.
</div>
</div>
<small><i>Code Review Run #ed5064</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-4956776680
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers a full AI review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #41907:
URL: https://github.com/apache/superset/pull/41907#issuecomment-5706358850
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(explore): drill-down hierarchy for ECharts charts [superset]
Posted by "sadpandajoe (via GitHub)" <gi...@apache.org>.
sadpandajoe commented on code in PR #41907:
URL: https://github.com/apache/superset/pull/41907#discussion_r3829003906
##########
superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/drillFilters.test.ts:
##########
@@ -0,0 +1,119 @@
+/**
+ * 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 { AxisType } from '@superset-ui/core';
+import {
+ buildTimeseriesDrillFilters,
Review Comment:
These tests only exercise `buildTimeseriesDrillFilters`, but the current click handler still constructs the filters inline in `EchartsTimeseries.tsx`. A regression in the actual handler (for example, dropping the series-event guard or the orientation-aware extraction) leaves this suite green. Could this cover the real click path or have that handler call the tested helper?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org