Compare commits

...

13 Commits

Author SHA1 Message Date
Maxime Beauchemin
d2d2cee1a7 refactor a dup logic 2025-09-17 21:24:42 -07:00
Maxime Beauchemin
02790b750f refactor a dup logic 2025-09-17 21:24:42 -07:00
Maxime Beauchemin
dd3b89620e fix last test 2025-09-17 21:24:42 -07:00
Maxime Beauchemin
d4d02c5104 refactor: Add generateExploreUrl utility to centralize URL generation
Simplifies repeated postFormData + mountExploreUrl + dashboard_page_id pattern
used across 6+ files by providing a clean, type-safe async utility.

Benefits:
- DrillDetailModal: 15 lines → 5 lines (67% reduction)
- Centralized URL generation logic for better maintainability
- Type-safe options parameter for chartId, tabId, dashboardPageId
- Comprehensive test coverage with 4 test scenarios
- Better error handling and promise chain management

Files refactored:
- DrillDetailModal: Simplified async URL generation
- DrillByModal: Clean promise chain with proper error handling
- SqlLab ResultSet: Modern async/await pattern
- Added comprehensive utility tests

All component tests passing 

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:42 -07:00
Maxime Beauchemin
41e7f51f4b fix: Drill-to-detail context menu filters now display as dashboard-inherited filters
- Mark drill-to-detail filters with `isExtra: true` in `getFormDataWithDashboardContext`
- Ensures context menu filters (e.g., "drill to details by -> Canada") appear as
  dashboard-inherited filters rather than chart-native filters in explore page
- Maintains consistency with filter bar filters which already display correctly
- Updated `getFormDataWithDashboardContext` to accept optional `drillToDetailFilters` parameter

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:42 -07:00
Maxime Beauchemin
843b81c34d feat: improve drill-through modal functionality and navigation
- Make Explore button visible only when drill-through chart is configured
- Change button text from "Edit chart" to "Explore" for clarity
- Fix Explore button to navigate to correct drill-through chart instead of dashboard
- Add proper dashboard context resolution using useDashboardFormData hook
- Use getFormDataWithDashboardContext for proper filter and context mixing
- Simplify StatefulChart rendering with proper formDataOverrides
- Generate explore URLs using getExploreUrl with GET method for reliability
- Add error handling for URL generation with user-friendly toast messages
- Remove duplicate filter conversion logic and use established patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:42 -07:00
Maxime Beauchemin
cf4fd3e389 feat: enhance drill-through modal Edit chart button visibility and navigation
- Only show Edit chart button when drill-through chart is configured
- Fix navigation to properly route to explore page instead of dashboard
- Use Button href for same-tab navigation (no target="_blank")
- Add Dataset.id type definition for URL generation
- Update tests to verify link behavior and required dataset properties

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:42 -07:00
Maxime Beauchemin
abb6069d22 fix two tests 2025-09-17 21:24:41 -07:00
Maxime Beauchemin
e05061bdb2 refactor: Simplify SelectAsyncControl by leveraging SupersetClient searchParams
Replace manual URL construction with SupersetClient's built-in searchParams support.
Rename queryParams prop to searchParams for API alignment and eliminate 12 lines
of URL building logic.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:41 -07:00
Maxime Beauchemin
d8ee744365 refactor: Improve ChartSelect type safety and URL building utilities
- Replace manual URL construction in DrillDetailModal with getExploreUrl utility
- Refactor ChartSelect to derive props from SelectAsyncControl using ComponentProps
- Add comprehensive test coverage for ChartSelect and useDashboardFormData components
- Update DatasourceEditor to use allowClear instead of deprecated clearable prop
- Use rest pattern for better prop forwarding and type safety

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:41 -07:00
Maxime Beauchemin
06e64115aa refactor: Add dashboard filter context to drill-through StatefulChart
- Create DashboardContextFormData type for explicit dashboard context contract
- Add useDashboardFormData hook to encapsulate complex dashboard filter logic
- Simplify DrillDetailPane from 40+ lines to 4 lines using new hook
- Fix Edit Chart link to open drill-through chart instead of original chart
- Ensure StatefulChart inherits dashboard native filters, colors, and context

This ensures drill-through charts get the same dashboard context as regular
dashboard charts, providing consistent filter inheritance and user experience.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:41 -07:00
Maxime Beauchemin
56152c2366 fix: Ensure drill-through chart field works consistently across CRUD and Explore paths
Fixes two issues with the drill-through chart configuration:

1. **SelectAsyncControl crash on clear**: Fixed isLabeledValue function to handle null values properly when clearing selections
2. **Missing field in Explore path**: Added drill_through_chart_id to both dataset API response and SqlaTable.data property serialization

This ensures the drill-through chart field loads and saves correctly whether accessed from:
- Datasets CRUD interface
- Explore → Edit Dataset flow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:24:41 -07:00
Maxime Beauchemin
a891ccdb10 fix package-lock 2025-09-17 21:24:40 -07:00
32 changed files with 1222 additions and 71 deletions

View File

@@ -6781,6 +6781,16 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/source-map/node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@jest/test-result": {
"version": "30.0.2",
"resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.2.tgz",
@@ -20795,15 +20805,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camel-case": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
@@ -44945,6 +44946,15 @@
"node": ">=6"
}
},
"node_modules/parent-module/node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse-conflict-json": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz",

View File

@@ -64,8 +64,7 @@ import {
SaveDatasetModal,
} from 'src/SqlLab/components/SaveDatasetModal';
import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { generateExploreUrl } from 'src/explore/exploreUtils/formData';
import ProgressBar from '@superset-ui/core/components/ProgressBar';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
@@ -78,7 +77,6 @@ import {
reFetchQueryResults,
reRunQuery,
} from 'src/SqlLab/actions/sqlLab';
import { URL_PARAMS } from 'src/constants';
import useLogAction from 'src/logger/useLogAction';
import {
LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD,
@@ -277,16 +275,13 @@ const ResultSet = ({
const openInNewWindow = clickEvent.metaKey;
logAction(LOG_ACTIONS_SQLLAB_CREATE_CHART, {});
if (results?.query_id) {
const key = await postFormData(results.query_id, 'query', {
const url = await generateExploreUrl(results.query_id, 'query', {
...EXPLORE_CHART_DEFAULT,
datasource: `${results.query_id}__query`,
...{
all_columns: results.columns.map(column => column.column_name),
},
});
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
if (openInNewWindow) {
window.open(url, '_blank', 'noreferrer');
} else {

View File

@@ -43,7 +43,7 @@ import {
} from '@superset-ui/core/components';
import { RootState } from 'src/dashboard/types';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { generateExploreUrl } from 'src/explore/exploreUtils/formData';
import { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
import { useToasts } from 'src/components/MessageToasts/withToasts';
@@ -96,11 +96,12 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
useEffect(() => {
// short circuit if the user is embedded as explore is not available
if (isEmbedded()) return;
postFormData(Number(datasource_id), datasource_type, formData, 0)
.then(key => {
setUrl(
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
);
generateExploreUrl(Number(datasource_id), datasource_type, formData, {
chartId: 0,
dashboardPageId,
})
.then(url => {
setUrl(url);
})
.catch(() => {
addDangerToast(t('Failed to generate chart edit URL'));

View File

@@ -32,6 +32,14 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('src/explore/exploreUtils', () => ({
...jest.requireActual('src/explore/exploreUtils'),
getExploreUrl: jest.fn(
({ formData }) =>
`/explore/?dashboard_page_id=&slice_id=${formData.slice_id}`,
),
}));
const { id: chartId, form_data: formData } = chartQueries[sliceId];
const { slice_name: chartName } = formData;
const store = getMockStoreWithNativeFilters();
@@ -43,7 +51,10 @@ const drillToDetailModalState = {
},
};
const renderModal = async (overrideState: Record<string, any> = {}) => {
const renderModal = async (
overrideState: Record<string, any> = {},
dataset?: any,
) => {
const DrillDetailModalWrapper = () => {
const [showModal, setShowModal] = useState(false);
return (
@@ -57,6 +68,7 @@ const renderModal = async (overrideState: Record<string, any> = {}) => {
initialFilters={[]}
showModal={showModal}
onHideModal={() => setShowModal(false)}
dataset={dataset}
/>
</>
);
@@ -80,11 +92,21 @@ test('should render the title', async () => {
expect(screen.getByText(`Drill to detail: ${chartName}`)).toBeInTheDocument();
});
test('should render the button', async () => {
test('should not render Explore button when no drill-through chart is configured', async () => {
await renderModal();
expect(
screen.getByRole('button', { name: 'Edit chart' }),
).toBeInTheDocument();
screen.queryByRole('button', { name: 'Explore' }),
).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(2);
});
test('should render Explore button when drill-through chart is configured', async () => {
const datasetWithDrillThrough = {
drill_through_chart_id: 123,
id: 456, // Required for URL generation
};
await renderModal({}, datasetWithDrillThrough);
expect(screen.getByRole('button', { name: 'Explore' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(2);
});
@@ -95,20 +117,19 @@ test('should close the modal', async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('should forward to Explore', async () => {
await renderModal();
userEvent.click(screen.getByRole('button', { name: 'Edit chart' }));
expect(mockHistoryPush).toHaveBeenCalledWith(
`/explore/?dashboard_page_id=&slice_id=${sliceId}`,
);
});
test('should render "Edit chart" as disabled without can_explore permission', async () => {
await renderModal({
user: {
...drillToDetailModalState.user,
roles: { Admin: [['invalid_permission', 'Superset']] },
test('should render "Explore" as disabled without can_explore permission', async () => {
const datasetWithDrillThrough = {
drill_through_chart_id: 123,
id: 456, // Required for URL generation
};
await renderModal(
{
user: {
...drillToDetailModalState.user,
roles: { Admin: [['invalid_permission', 'Superset']] },
},
},
});
expect(screen.getByRole('button', { name: 'Edit chart' })).toBeDisabled();
datasetWithDrillThrough,
);
expect(screen.getByRole('button', { name: 'Explore' })).toBeDisabled();
});

View File

@@ -17,8 +17,7 @@
* under the License.
*/
import { useCallback, useContext, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { useContext, useMemo, useState } from 'react';
import {
BinaryQueryObjectFilterClause,
css,
@@ -33,37 +32,46 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Slice } from 'src/types/Chart';
import { RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { getFormDataWithDashboardContext } from 'src/explore/controlUtils/getFormDataWithDashboardContext';
import { useDashboardFormData } from 'src/dashboard/hooks/useDashboardFormData';
import { generateExploreUrl } from 'src/explore/exploreUtils/formData';
import { Dataset } from '../types';
import DrillDetailPane from './DrillDetailPane';
interface ModalFooterProps {
canExplore: boolean;
closeModal?: () => void;
exploreChart: () => void;
showEditButton: boolean;
onExploreClick?: (event: React.MouseEvent) => void;
isGeneratingUrl: boolean;
}
const ModalFooter = ({
canExplore,
closeModal,
exploreChart,
showEditButton,
onExploreClick,
isGeneratingUrl,
}: ModalFooterProps) => {
const theme = useTheme();
return (
<>
{!isEmbedded() && (
{!isEmbedded() && showEditButton && (
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
onClick={canExplore ? onExploreClick : undefined}
disabled={!canExplore || isGeneratingUrl}
loading={isGeneratingUrl}
tooltip={
!canExplore
? t('You do not have sufficient permissions to edit the chart')
? t('You do not have sufficient permissions to explore the chart')
: undefined
}
>
{t('Edit chart')}
{t('Explore')}
</Button>
)}
<Button
@@ -99,8 +107,10 @@ export default function DrillDetailModal({
dataset,
}: DrillDetailModalProps) {
const theme = useTheme();
const history = useHistory();
const dashboardPageId = useContext(DashboardPageIdContext);
const { addDangerToast } = useToasts();
const [isGeneratingUrl, setIsGeneratingUrl] = useState(false);
const { slice_name: chartName } = useSelector(
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
state.sliceEntities?.slices?.[chartId] || {},
@@ -109,14 +119,65 @@ export default function DrillDetailModal({
findPermission('can_explore', 'Superset', state.user?.roles),
);
const exploreUrl = useMemo(
() => `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${chartId}`,
[chartId, dashboardPageId],
const showEditButton = Boolean(dataset?.drill_through_chart_id);
const dashboardContextFormData = useDashboardFormData(
dataset?.drill_through_chart_id,
);
const exploreChart = useCallback(() => {
history.push(exploreUrl);
}, [exploreUrl, history]);
const drillThroughFormData = useMemo(() => {
if (!dataset?.drill_through_chart_id || !dataset?.id) {
return null;
}
const drillThroughBaseFormData = {
slice_id: dataset.drill_through_chart_id,
datasource: `${dataset.id}__table`,
viz_type: 'table',
};
return getFormDataWithDashboardContext(
drillThroughBaseFormData,
dashboardContextFormData,
undefined,
initialFilters,
);
}, [
dataset?.drill_through_chart_id,
dataset?.id,
dashboardContextFormData,
initialFilters,
]);
const handleExploreClick = async (event: React.MouseEvent) => {
event.preventDefault();
if (
!dataset?.drill_through_chart_id ||
!drillThroughFormData ||
!dataset?.id
) {
return;
}
setIsGeneratingUrl(true);
try {
const url = await generateExploreUrl(
dataset.id,
'table',
drillThroughFormData,
{
chartId: dataset.drill_through_chart_id,
dashboardPageId,
},
);
window.location.href = url;
} catch (error) {
console.error('Failed to generate chart explore URL:', error);
addDangerToast(t('Failed to generate chart explore URL'));
setIsGeneratingUrl(false);
}
};
return (
<Modal
@@ -131,7 +192,12 @@ export default function DrillDetailModal({
name={t('Drill to detail: %s', chartName)}
title={t('Drill to detail: %s', chartName)}
footer={
<ModalFooter exploreChart={exploreChart} canExplore={canExplore} />
<ModalFooter
canExplore={canExplore}
showEditButton={showEditButton}
onExploreClick={handleExploreClick}
isGeneratingUrl={isGeneratingUrl}
/>
}
responsive
resizable
@@ -151,6 +217,7 @@ export default function DrillDetailModal({
formData={formData}
initialFilters={initialFilters}
dataset={dataset}
drillThroughFormData={drillThroughFormData}
/>
</Modal>
);

View File

@@ -33,6 +33,7 @@ import {
GenericDataType,
JsonObject,
QueryFormData,
StatefulChart,
t,
useTheme,
} from '@superset-ui/core';
@@ -40,7 +41,7 @@ import { useResizeDetector } from 'react-resize-detector';
import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/BooleanCell';
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { EmptyState, Flex, Loading } from '@superset-ui/core/components';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import Table, {
ColumnsType,
@@ -81,10 +82,12 @@ export default function DrillDetailPane({
formData,
initialFilters,
dataset,
drillThroughFormData,
}: {
formData: QueryFormData;
initialFilters: BinaryQueryObjectFilterClause[];
dataset?: Dataset;
drillThroughFormData?: QueryFormData | null;
}) {
const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0);
@@ -161,7 +164,7 @@ export default function DrillDetailPane({
) : (
dataset?.verbose_map?.[column] || column
),
render: value => {
render: (value: any) => {
if (value === true || value === false) {
return <BooleanCell value={value} />;
}
@@ -233,6 +236,10 @@ export default function DrillDetailPane({
// Download page of results & trim cache if page not in cache
useEffect(() => {
// Skip table data fetching if we're using a drill-through chart
if (dataset?.drill_through_chart_id) {
return;
}
if (!responseError && !isLoading && !resultsPages.has(pageIndex)) {
setIsLoading(true);
const jsonPayload = getDrillPayload(formData, filters) ?? {};
@@ -282,12 +289,27 @@ export default function DrillDetailPane({
resultsPages,
]);
const bootstrapping = !responseError && !resultsPages.size;
const bootstrapping =
!dataset?.drill_through_chart_id && !responseError && !resultsPages.size;
const allowHTML = formData.allow_render_html ?? true;
let tableContent = null;
if (responseError) {
// If a drill-through chart is configured, use it instead of the table
if (dataset?.drill_through_chart_id && drillThroughFormData) {
tableContent = (
<Flex vertical style={{ height: '100%' }}>
<StatefulChart
chartId={dataset.drill_through_chart_id}
formDataOverrides={drillThroughFormData}
height="100%"
width="100%"
showLoading
/>
</Flex>
);
} else if (responseError) {
// Render error if page download failed
tableContent = (
<pre
@@ -331,7 +353,7 @@ export default function DrillDetailPane({
return (
<>
{!bootstrapping && metadataBarComponent}
{!bootstrapping && (
{!bootstrapping && !dataset?.drill_through_chart_id && (
<TableControls
filters={filters}
setFilters={setFilters}

View File

@@ -24,6 +24,7 @@ export enum DrillByType {
}
export type Dataset = {
id?: number;
changed_by?: {
first_name: string;
last_name: string;
@@ -44,4 +45,5 @@ export type Dataset = {
drillable_columns?: Column[];
metrics?: Metric[];
verbose_map?: Record<string, string>;
drill_through_chart_id?: number | null;
};

View File

@@ -46,6 +46,7 @@ import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import type { DatasourceModalProps } from '../types';
import { invalidateDatasetDrillCache } from 'src/utils/cachedSupersetGet';
const DatasourceEditor = AsyncEsmComponent(
() => import('../components/DatasourceEditor'),
@@ -181,6 +182,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
owners: datasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
drill_through_chart_id: datasource.drill_through_chart_id || null,
};
// Handle catalog based on database's allow_multi_catalog setting
// If multi-catalog is disabled, don't include catalog in payload
@@ -203,6 +205,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
// Invalidate drill info cache to pick up any drill-through config changes
invalidateDatasetDrillCache(currentDatasource.id);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
json.result.type = 'table';

View File

@@ -75,6 +75,7 @@ import Fieldset from '../Fieldset';
import Field from '../Field';
import { fetchSyncedColumns, updateColumns } from '../../utils';
import DatasetUsageTab from './components/DatasetUsageTab';
import ChartSelect from '../Select/ChartSelect';
const extensionsRegistry = getExtensionsRegistry();
@@ -1054,6 +1055,25 @@ class DatasourceEditor extends PureComponent {
}
/>
)}
{this.state.isSqla && (
<Field
fieldKey="drill_through_chart_id"
value={datasource.drill_through_chart_id}
onChange={this.onDatasourcePropChange}
label={t('Drill-to-details table/chart')}
description={t(
'Select a chart to display when users drill into this dataset. If not configured, shows all columns in a table.',
)}
control={
<ChartSelect
datasetId={datasource.id}
placeholder={t('Default (show all columns)')}
allowClear
ariaLabel={t('Select drill-to-details chart')}
/>
}
/>
)}
{this.state.isSqla && (
<Field
fieldKey="extra"

View File

@@ -0,0 +1,115 @@
/**
* 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 } from 'spec/helpers/testing-library';
import ChartSelectUsingAsync from './ChartSelect';
const mockOnChange = jest.fn();
const defaultProps = {
value: null,
onChange: mockOnChange,
datasetId: 123,
placeholder: 'Select a chart',
allowClear: true,
ariaLabel: 'Select drill-to-details chart',
};
test('renders chart select component with default props', () => {
const { container } = render(<ChartSelectUsingAsync {...defaultProps} />, {
useRedux: true,
});
expect(container.firstChild).toBeInTheDocument();
});
test('renders with custom placeholder', () => {
const customProps = {
...defaultProps,
placeholder: 'Choose your chart',
};
const { container } = render(<ChartSelectUsingAsync {...customProps} />, {
useRedux: true,
});
expect(container.firstChild).toBeInTheDocument();
});
test('renders with selected value', () => {
const propsWithValue = {
...defaultProps,
value: 456,
};
const { container } = render(<ChartSelectUsingAsync {...propsWithValue} />, {
useRedux: true,
});
expect(container.firstChild).toBeInTheDocument();
});
test('renders without dataset filter when datasetId is undefined', () => {
const propsWithoutDataset = {
...defaultProps,
datasetId: undefined,
};
const { container } = render(
<ChartSelectUsingAsync {...propsWithoutDataset} />,
{ useRedux: true },
);
expect(container.firstChild).toBeInTheDocument();
});
test('renders with custom aria label', () => {
const propsWithCustomAriaLabel = {
...defaultProps,
ariaLabel: 'Custom chart selector',
};
const { container } = render(
<ChartSelectUsingAsync {...propsWithCustomAriaLabel} />,
{ useRedux: true },
);
expect(container.firstChild).toBeInTheDocument();
});
test('renders as non-clearable when allowClear is false', () => {
const nonClearableProps = {
...defaultProps,
allowClear: false,
};
const { container } = render(
<ChartSelectUsingAsync {...nonClearableProps} />,
{ useRedux: true },
);
expect(container.firstChild).toBeInTheDocument();
});
test('passes through additional props to SelectAsyncControl', () => {
const propsWithExtra = {
...defaultProps,
description: 'Test description',
hovered: true,
'data-testid': 'chart-select',
};
const { container } = render(<ChartSelectUsingAsync {...propsWithExtra} />, {
useRedux: true,
});
expect(container.firstChild).toBeInTheDocument();
});

View File

@@ -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 { useMemo } from 'react';
import { t } from '@superset-ui/core';
import SelectAsyncControl from 'src/explore/components/controls/SelectAsyncControl';
import type { ComponentProps } from 'react';
import rison from 'rison';
// Extract the actual props from SelectAsyncControl component
type SelectAsyncControlProps = ComponentProps<typeof SelectAsyncControl>;
export interface ChartSelectProps
extends Omit<
SelectAsyncControlProps,
'onChange' | 'dataEndpoint' | 'mutator' | 'addDangerToast'
> {
// ChartSelect-specific props that override base props
value?: number | null;
onChange: (value: number | null) => void;
datasetId?: number;
}
/**
* A chart selection component built on SelectAsyncControl
* @param value - The selected chart ID
* @param onChange - Callback when selection changes
* @param datasetId - Optional dataset ID to filter charts
* @param placeholder - Optional placeholder text
* @param ariaLabel - ARIA label for accessibility
* @param rest - All other props are passed through to SelectAsyncControl
*/
export default function ChartSelectUsingAsync({
value,
onChange,
datasetId,
placeholder = t('Select a chart'),
ariaLabel = t('Select drill-to-details chart'),
...rest
}: ChartSelectProps) {
// Build query parameters for filtering charts by dataset
const queryParams = useMemo(() => {
if (!datasetId) return undefined;
const filters = [
{
col: 'datasource_id',
opr: 'eq',
value: datasetId,
},
{
col: 'datasource_type',
opr: 'eq',
value: 'table',
},
];
return {
q: rison.encode({
filters,
order_column: 'slice_name',
order_direction: 'asc',
}),
};
}, [datasetId]);
// Transform response to format expected by SelectAsyncControl
const mutator = useMemo(
() => (response: any) =>
response.result.map((chart: any) => ({
value: chart.id,
label: `${chart.slice_name} (${chart.viz_type})`,
})),
[],
);
return (
<SelectAsyncControl
ariaLabel={ariaLabel}
dataEndpoint="/api/v1/chart/"
searchParams={queryParams}
mutator={mutator}
value={value}
onChange={onChange}
placeholder={placeholder}
multi={false}
{...rest}
/>
);
}

View File

@@ -0,0 +1,171 @@
/**
* 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 { renderHook } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { useDashboardFormData } from './useDashboardFormData';
const mockStore = configureMockStore([]);
const createMockState = (overrides = {}) => ({
dashboardInfo: {
id: 123,
metadata: {
chart_configuration: {},
},
},
dashboardState: {
sliceIds: [1, 2, 3],
},
nativeFilters: {
filters: {},
},
dataMask: {},
...overrides,
});
const renderUseDashboardFormData = (
chartId: number | null | undefined,
state = {},
) => {
const store = mockStore(createMockState(state));
return renderHook(() => useDashboardFormData(chartId), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});
};
test('returns base dashboard context when chartId is null', () => {
const { result } = renderUseDashboardFormData(null);
expect(result.current).toEqual({ dashboardId: 123 });
});
test('returns base dashboard context when chartId is undefined', () => {
const { result } = renderUseDashboardFormData(undefined);
expect(result.current).toEqual({ dashboardId: 123 });
});
test('returns base dashboard context when required state is missing', () => {
const { result } = renderUseDashboardFormData(1, {
nativeFilters: null,
});
expect(result.current).toEqual({ dashboardId: 123 });
});
test('returns base dashboard context when no filters apply to chart', () => {
const { result } = renderUseDashboardFormData(1, {
nativeFilters: {
filters: {
'filter-1': {
scope: [2, 3], // Doesn't include chartId 1
},
},
},
dashboardInfo: {
id: 123,
metadata: {
chart_configuration: {
'filter-1': {
id: 'filter-1',
scope: [2, 3],
},
},
},
},
});
expect(result.current).toEqual({ dashboardId: 123 });
});
test('returns dashboard context with extra form data when filters apply', () => {
const mockState = {
nativeFilters: {
filters: {
'filter-1': {
scope: [1, 2, 3], // Includes chartId 1
},
},
},
dashboardInfo: {
id: 123,
metadata: {
chart_configuration: {
'filter-1': {
id: 'filter-1',
scope: [1, 2, 3],
},
},
},
},
dataMask: {
'filter-1': {
extraFormData: {
filters: [
{
col: 'country',
op: 'IN',
val: ['USA', 'Canada'],
},
],
},
},
},
};
// Mock the external utility functions
jest.mock('../util/activeAllDashboardFilters', () => ({
getAllActiveFilters: () => ({
'filter-1': {
scope: [1, 2, 3],
},
}),
}));
jest.mock('../components/nativeFilters/utils', () => ({
getExtraFormData: () => ({
filters: [
{
col: 'country',
op: 'IN',
val: ['USA', 'Canada'],
},
],
}),
}));
const { result } = renderUseDashboardFormData(1, mockState);
expect(result.current.dashboardId).toBe(123);
expect(result.current.extra_form_data).toBeDefined();
});
test('handles different dashboard IDs correctly', () => {
const { result } = renderUseDashboardFormData(1, {
dashboardInfo: {
id: 456,
metadata: {
chart_configuration: {},
},
},
});
expect(result.current).toEqual({ dashboardId: 456 });
});

View File

@@ -0,0 +1,108 @@
/**
* 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 { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState, DashboardContextFormData } from '../types';
import { getExtraFormData } from '../components/nativeFilters/utils';
import { getAllActiveFilters } from '../util/activeAllDashboardFilters';
import { getFilterIdsAppliedOnChart } from '../util/getFilterIdsAppliedOnChart';
/**
* Hook that provides dashboard context as formatted formData for charts.
* This encapsulates all the complex logic for determining which dashboard
* filters, colors, and other context should be applied to a specific chart.
*
* @param chartId - The ID of the chart to get dashboard context for
* @returns Dashboard context formatted as QueryFormData fields
*/
export const useDashboardFormData = (
chartId: number | null | undefined,
): DashboardContextFormData => {
// Dashboard state selectors
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const nativeFilters = useSelector(
(state: RootState) => state.nativeFilters?.filters,
);
const dataMask = useSelector((state: RootState) => state.dataMask);
const chartConfiguration = useSelector(
(state: RootState) =>
state.dashboardInfo.metadata?.chart_configuration || {},
);
const allSliceIds = useSelector(
(state: RootState) => state.dashboardState.sliceIds,
);
// Compute dashboard context for the chart
return useMemo((): DashboardContextFormData => {
const baseContext: DashboardContextFormData = { dashboardId };
// Early return if we don't have required data or chartId
if (
!chartId ||
!nativeFilters ||
!dataMask ||
!chartConfiguration ||
!allSliceIds
) {
return baseContext;
}
// Get active filters using the same logic as normal dashboard charts
const activeFilters = getAllActiveFilters({
chartConfiguration,
nativeFilters,
dataMask,
allSliceIds,
});
// Find which filters apply to this specific chart
const filterIdsAppliedOnChart = getFilterIdsAppliedOnChart(
activeFilters,
chartId,
);
// If no filters apply, return just the base context
if (filterIdsAppliedOnChart.length === 0) {
return baseContext;
}
// Get the extra form data from dashboard filters
const extraFormData = getExtraFormData(dataMask, filterIdsAppliedOnChart);
return {
...baseContext,
extra_form_data: extraFormData,
// TODO: Add other dashboard context like color schemes when needed
};
}, [
chartId,
dashboardId,
nativeFilters,
dataMask,
chartConfiguration,
allSliceIds,
]);
};

View File

@@ -26,6 +26,7 @@ import {
NativeFilterScope,
NativeFiltersState,
NativeFilterTarget,
QueryFormData,
} from '@superset-ui/core';
import { Dataset } from '@superset-ui/chart-controls';
import { chart } from 'src/components/Chart/chartReducer';
@@ -299,3 +300,31 @@ export enum MenuKeys {
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
}
/**
* Represents the dashboard context that can be applied to a chart's formData.
* This type defines the specific formData fields that dashboard components
* can provide to integrate charts with the current dashboard state.
*/
export interface DashboardContextFormData extends Partial<QueryFormData> {
/** The ID of the current dashboard */
dashboardId: number;
/** Dashboard native filters applied to the chart */
extra_form_data?: ExtraFormData;
/** Dashboard color scheme */
color_scheme?: string;
/** Dashboard color namespace */
color_namespace?: string;
/** Dashboard label colors mapping */
label_colors?: Record<string, string>;
/** Dashboard shared label colors */
shared_label_colors?: string[];
/** Dashboard map label colors */
map_label_colors?: Record<string, string>;
}

View File

@@ -34,6 +34,7 @@ import { areObjectsEqual } from 'src/reduxUtils';
import { isEqual } from 'lodash';
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
import { getAllActiveFilters } from '../activeAllDashboardFilters';
import { getFilterIdsAppliedOnChart } from '../getFilterIdsAppliedOnChart';
interface CachedFormData {
extra_form_data?: JsonObject;
@@ -157,9 +158,10 @@ export default function getFormDataWithExtraFilters({
});
let extraData: JsonObject = {};
const filterIdsAppliedOnChart = Object.entries(activeFilters)
.filter(([, activeFilter]) => activeFilter.scope.includes(chart.id))
.map(([filterId]) => filterId);
const filterIdsAppliedOnChart = getFilterIdsAppliedOnChart(
activeFilters,
chart.id,
);
if (filterIdsAppliedOnChart.length) {
const aggregatedFormData = getExtraFormData(

View File

@@ -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 { getFilterIdsAppliedOnChart } from './getFilterIdsAppliedOnChart';
import { ActiveFilters } from '../types';
describe('getFilterIdsAppliedOnChart', () => {
const createMockActiveFilters = (
filterConfigs: Array<{ id: string; scope: number[] }>,
): ActiveFilters => {
const activeFilters: ActiveFilters = {};
filterConfigs.forEach(({ id, scope }) => {
activeFilters[id] = {
scope,
targets: [],
values: {},
};
});
return activeFilters;
};
test('returns filters that include chart in their scope', () => {
const activeFilters = createMockActiveFilters([
{ id: 'filter-1', scope: [1, 2, 3] },
{ id: 'filter-2', scope: [2, 4, 5] },
{ id: 'filter-3', scope: [1, 3, 6] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 2);
expect(result).toEqual(['filter-1', 'filter-2']);
});
test('returns empty array when no filters apply to chart', () => {
const activeFilters = createMockActiveFilters([
{ id: 'filter-1', scope: [1, 3, 5] },
{ id: 'filter-2', scope: [7, 8, 9] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 2);
expect(result).toEqual([]);
});
test('returns empty array when activeFilters is empty', () => {
const activeFilters: ActiveFilters = {};
const result = getFilterIdsAppliedOnChart(activeFilters, 1);
expect(result).toEqual([]);
});
test('handles single filter with single chart scope', () => {
const activeFilters = createMockActiveFilters([
{ id: 'single-filter', scope: [42] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 42);
expect(result).toEqual(['single-filter']);
});
test('handles multiple filters all applying to same chart', () => {
const activeFilters = createMockActiveFilters([
{ id: 'filter-a', scope: [1, 2] },
{ id: 'filter-b', scope: [1] },
{ id: 'filter-c', scope: [1, 3, 4] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 1);
expect(result).toEqual(['filter-a', 'filter-b', 'filter-c']);
});
test('preserves filter ID order from Object.entries', () => {
const activeFilters = createMockActiveFilters([
{ id: 'zebra', scope: [1] },
{ id: 'alpha', scope: [1] },
{ id: 'beta', scope: [1] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 1);
// Object.entries preserves insertion order in modern JS
expect(result).toEqual(['zebra', 'alpha', 'beta']);
});
test('handles edge case with large chart IDs', () => {
const activeFilters = createMockActiveFilters([
{ id: 'filter-large', scope: [999999, 1000000, 1000001] },
]);
const result = getFilterIdsAppliedOnChart(activeFilters, 1000000);
expect(result).toEqual(['filter-large']);
});
});

View File

@@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ActiveFilters } from '../types';
/**
* Returns the filter IDs that apply to a specific chart based on their scope.
* This centralizes the logic for determining which dashboard filters
* should be applied to a given chart.
*
* @param activeFilters - The currently active dashboard filters
* @param chartId - The ID of the chart to check filter scope for
* @returns Array of filter IDs that apply to the specified chart
*/
export const getFilterIdsAppliedOnChart = (
activeFilters: ActiveFilters,
chartId: number,
): string[] =>
Object.entries(activeFilters)
.filter(([, activeFilter]) => activeFilter.scope.includes(chartId))
.map(([filterId]) => filterId);

View File

@@ -104,3 +104,15 @@ test('Should send correct props to Select component - function onChange multi:fa
userEvent.click(await screen.findByText('onChange'));
expect(props.onChange).toHaveBeenCalledTimes(1);
});
test('Should handle null value without crashing when clearing selection', () => {
const props = createProps();
const { rerender } = render(<SelectAsyncControl {...props} />, {
useRedux: true,
});
// Simulate clearing the selection by passing null value
expect(() => {
rerender(<SelectAsyncControl {...props} value={null} />);
}).not.toThrow();
});

View File

@@ -41,6 +41,8 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
) => SelectOptionsType;
multi?: boolean;
onChange: (val: SelectValue) => void;
// Optional search parameters to append to the endpoint
searchParams?: Record<string, any>;
// ControlHeader related props
description?: string;
hovered?: boolean;
@@ -48,7 +50,7 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
}
function isLabeledValue(arg: any): arg is LabeledValue {
return arg.value !== undefined;
return arg && typeof arg === 'object' && arg.value !== undefined;
}
const SelectAsyncControl = ({
@@ -60,6 +62,7 @@ const SelectAsyncControl = ({
mutator,
onChange,
placeholder,
searchParams,
value,
...props
}: SelectAsyncControlProps) => {
@@ -98,6 +101,7 @@ const SelectAsyncControl = ({
const loadOptions = () =>
SupersetClient.get({
endpoint: dataEndpoint,
searchParams,
})
.then(response => {
const data = mutator
@@ -113,7 +117,7 @@ const SelectAsyncControl = ({
if (!loaded) {
loadOptions();
}
}, [addDangerToast, dataEndpoint, mutator, value, loaded]);
}, [addDangerToast, dataEndpoint, mutator, value, loaded, searchParams]);
return (
<Select

View File

@@ -19,6 +19,7 @@
import { isEqual } from 'lodash';
import {
AdhocFilter,
BinaryQueryObjectFilterClause,
ensureIsArray,
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
@@ -195,6 +196,7 @@ export const getFormDataWithDashboardContext = (
exploreFormData: QueryFormData,
dashboardContextFormData: JsonObject,
saveAction?: string | null,
drillToDetailFilters?: BinaryQueryObjectFilterClause[],
) => {
const filterBoxData = mergeFilterBoxToFormData(
exploreFormData,
@@ -205,6 +207,15 @@ export const getFormDataWithDashboardContext = (
exploreFormData,
dashboardContextFormData,
);
// Handle drill-to-detail filters (e.g., drill-down filters from context menu)
const drillToDetailFiltersData: JsonObject = {};
if (drillToDetailFilters && drillToDetailFilters.length > 0) {
drillToDetailFiltersData.adhoc_filters = drillToDetailFilters.map(filter =>
simpleFilterToAdhoc({ ...filter, isExtra: true }),
);
}
const isDeckGLChart =
exploreFormData.viz_type === 'deck_multi' ||
dashboardContextFormData.viz_type === 'deck_multi';
@@ -215,6 +226,7 @@ export const getFormDataWithDashboardContext = (
...Object.keys(exploreFormData),
...Object.keys(filterBoxData),
...Object.keys(nativeFiltersData),
...Object.keys(drillToDetailFiltersData),
]
.filter(key => key.match(/adhoc_filter.*/))
.reduce(
@@ -225,6 +237,7 @@ export const getFormDataWithDashboardContext = (
...ensureIsArray(exploreFormData[key]),
...ensureIsArray(filterBoxData[key]),
...ensureIsArray(nativeFiltersData[key]),
...ensureIsArray(drillToDetailFiltersData[key]),
];
const afterDuplicates = removeAdhocFilterDuplicates(beforeDuplicates);
@@ -280,6 +293,7 @@ export const getFormDataWithDashboardContext = (
...dashboardContextFormData,
...filterBoxData,
...nativeFiltersData,
...drillToDetailFiltersData,
...adhocFilters,
...exploreFormData, // Explore form data comes last to override
own_color_scheme: ownColorScheme,
@@ -296,6 +310,7 @@ export const getFormDataWithDashboardContext = (
...dashboardContextFormData,
...filterBoxData,
...nativeFiltersData,
...drillToDetailFiltersData,
...adhocFilters,
own_color_scheme: ownColorScheme,
color_scheme: appliedColorScheme,

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { postFormData, putFormData } from './formData';
import { postFormData, putFormData, generateExploreUrl } from './formData';
import { mountExploreUrl } from './index';
jest.mock('@superset-ui/core', () => ({
SupersetClient: {
@@ -26,6 +27,18 @@ jest.mock('@superset-ui/core', () => ({
},
}));
jest.mock('./index', () => ({
mountExploreUrl: jest.fn(),
}));
jest.mock('src/constants', () => ({
URL_PARAMS: {
formDataKey: {
name: 'form_data_key',
},
},
}));
test('postFormData should call SupersetClient.post with correct payload and return key', async () => {
const mockKey = '123abc';
const mockResponse = { json: { key: mockKey } };
@@ -96,3 +109,109 @@ test('postFormData without optional params should work', async () => {
},
});
});
describe('generateExploreUrl', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should generate explore URL without optional parameters', async () => {
const mockKey = 'test-key-123';
const mockBaseUrl = '/explore/?form_data_key=test-key-123';
(SupersetClient.post as jest.Mock).mockResolvedValue({
json: { key: mockKey },
});
(mountExploreUrl as jest.Mock).mockReturnValue(mockBaseUrl);
const result = await generateExploreUrl(1, 'table', { viz_type: 'table' });
expect(SupersetClient.post).toHaveBeenCalledWith({
endpoint: 'api/v1/explore/form_data',
jsonPayload: {
datasource_id: 1,
datasource_type: 'table',
form_data: JSON.stringify({ viz_type: 'table' }),
},
});
expect(mountExploreUrl).toHaveBeenCalledWith(null, {
form_data_key: mockKey,
});
expect(result).toBe(mockBaseUrl);
});
test('should generate explore URL with all optional parameters', async () => {
const mockKey = 'test-key-456';
const mockBaseUrl = '/explore/?form_data_key=test-key-456';
const mockFinalUrl =
'/explore/?form_data_key=test-key-456&dashboard_page_id=dashboard-123';
(SupersetClient.post as jest.Mock).mockResolvedValue({
json: { key: mockKey },
});
(mountExploreUrl as jest.Mock).mockReturnValue(mockBaseUrl);
const result = await generateExploreUrl(
2,
'table',
{ viz_type: 'table', slice_id: 42 },
{
chartId: 42,
tabId: 'tab-1',
dashboardPageId: 'dashboard-123',
},
);
expect(SupersetClient.post).toHaveBeenCalledWith({
endpoint: 'api/v1/explore/form_data?tab_id=tab-1',
jsonPayload: {
datasource_id: 2,
datasource_type: 'table',
form_data: JSON.stringify({ viz_type: 'table', slice_id: 42 }),
chart_id: 42,
},
});
expect(mountExploreUrl).toHaveBeenCalledWith(null, {
form_data_key: mockKey,
});
expect(result).toBe(mockFinalUrl);
});
test('should handle dashboard_page_id with existing query parameters', async () => {
const mockKey = 'test-key-789';
const mockBaseUrl = '/explore/?form_data_key=test-key-789&standalone=1';
(SupersetClient.post as jest.Mock).mockResolvedValue({
json: { key: mockKey },
});
(mountExploreUrl as jest.Mock).mockReturnValue(mockBaseUrl);
const result = await generateExploreUrl(
3,
'query',
{ viz_type: 'table' },
{
dashboardPageId: 'dashboard-456',
},
);
const expectedUrl =
'/explore/?form_data_key=test-key-789&standalone=1&dashboard_page_id=dashboard-456';
expect(result).toBe(expectedUrl);
});
test('should propagate errors from postFormData', async () => {
const mockError = new Error('Network error');
(SupersetClient.post as jest.Mock).mockRejectedValue(mockError);
await expect(
generateExploreUrl(1, 'table', { viz_type: 'table' }),
).rejects.toThrow('Network error');
expect(mountExploreUrl).not.toHaveBeenCalled();
});
});

View File

@@ -18,6 +18,8 @@
*/
import { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core';
import { sanitizeFormData } from 'src/utils/sanitizeFormData';
import { URL_PARAMS } from 'src/constants';
import { mountExploreUrl } from './index';
type Payload = {
datasource_id: number;
@@ -86,3 +88,48 @@ export const putFormData = (
chartId,
),
}).then((r: JsonResponse) => r.json.message);
/**
* Generate explore URL by posting formData to server and creating URL with key.
* This solves URL length limitations by storing complex formData server-side.
*
* @param datasourceId - The datasource ID
* @param datasourceType - The datasource type (typically 'table' or 'query')
* @param formData - The form data object to be posted
* @param options - Optional parameters
* @param options.chartId - Chart ID for the explore URL
* @param options.tabId - Tab ID for multi-tab environments
* @param options.dashboardPageId - Dashboard page ID to maintain context
* @returns Promise that resolves to the complete explore URL
*/
export const generateExploreUrl = async (
datasourceId: number,
datasourceType: string,
formData: JsonObject,
options?: {
chartId?: number;
tabId?: string;
dashboardPageId?: string;
},
): Promise<string> => {
// Post formData to server and get key
const key = await postFormData(
datasourceId,
datasourceType,
formData,
options?.chartId,
options?.tabId,
);
// Generate base explore URL with form_data_key
const baseUrl = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
// Add dashboard_page_id if provided
const finalUrl = options?.dashboardPageId
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}dashboard_page_id=${options.dashboardPageId}`
: baseUrl;
return finalUrl;
};

View File

@@ -119,9 +119,9 @@ describe('Footer', () => {
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
userEvent.click(dropdownTrigger);
// Check that the dropdown menu option is visible
// Check that the dropdown menu option is in the document
await waitFor(() => {
expect(screen.getByText('Create dataset')).toBeVisible();
expect(screen.getByText('Create dataset')).toBeInTheDocument();
});
});

View File

@@ -81,4 +81,5 @@ export type DatasetObject = {
column_formats: Record<string, string>;
datasource_name: string | null;
verbose_map: Record<string, string>;
drill_through_chart_id?: number | null;
};

View File

@@ -27,3 +27,20 @@ export const cachedSupersetGet = cacheWrapper(
supersetGetCache,
({ endpoint }) => endpoint || '',
);
/**
* Invalidates cached dataset drill info for all dashboards containing this dataset
*/
export const invalidateDatasetDrillCache = (datasetId: string | number) => {
const numericDatasetId =
typeof datasetId === 'string'
? Number(datasetId.split('__')[0])
: Number(datasetId);
// Find and delete all cache entries for this dataset's drill info
const keysToDelete = Array.from(supersetGetCache.keys()).filter(key =>
key.includes(`/api/v1/dataset/${numericDatasetId}/drill_info/`),
);
keysToDelete.forEach(key => supersetGetCache.delete(key));
};

View File

@@ -1148,6 +1148,16 @@ class SqlaTable(
always_filter_main_dttm = Column(Boolean, default=False)
folders = Column(JSON, nullable=True)
# Drill-through configuration
drill_through_chart_id = Column(
Integer, ForeignKey("slices.id", ondelete="SET NULL"), nullable=True
)
drill_through_chart: Mapped[Optional[Slice]] = relationship(
"Slice",
foreign_keys=[drill_through_chart_id],
backref=backref("drill_through_datasets", passive_deletes=True),
)
baselink = "tablemodelview"
export_fields = [
@@ -1169,6 +1179,7 @@ class SqlaTable(
"normalize_columns",
"always_filter_main_dttm",
"folders",
"drill_through_chart_id",
]
update_from_object_fields = [f for f in export_fields if f != "database_id"]
export_parent = "database"
@@ -1365,6 +1376,7 @@ class SqlaTable(
data_["owners"] = self.owners_data
data_["always_filter_main_dttm"] = self.always_filter_main_dttm
data_["normalize_columns"] = self.normalize_columns
data_["drill_through_chart_id"] = self.drill_through_chart_id
return data_
@property

View File

@@ -220,6 +220,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"changed_on_humanized",
"changed_by.first_name",
"changed_by.last_name",
"drill_through_chart_id",
]
show_columns = show_select_columns + [
"columns.type_generic",
@@ -236,6 +237,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"time_grain_sqla",
"order_by_choices",
"verbose_map",
"drill_through_chart_id",
]
add_model_schema = DatasetPostSchema()
edit_model_schema = DatasetPutSchema()
@@ -261,6 +263,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"columns",
"metrics",
"extra",
"drill_through_chart_id",
]
openapi_spec_tag = "Datasets"

View File

@@ -174,6 +174,7 @@ class DatasetPutSchema(Schema):
columns = fields.List(fields.Nested(DatasetColumnsPutSchema))
metrics = fields.List(fields.Nested(DatasetMetricsPutSchema))
folders = fields.List(fields.Nested(FolderSchema), required=False)
drill_through_chart_id = fields.Integer(allow_none=True)
extra = fields.String(allow_none=True)
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
external_url = fields.String(allow_none=True)
@@ -322,6 +323,7 @@ class ImportV1DatasetSchema(Schema):
fetch_values_predicate = fields.String(allow_none=True)
extra = fields.Dict(allow_none=True)
uuid = fields.UUID(required=True)
drill_through_chart_id = fields.Integer(allow_none=True)
columns = fields.List(fields.Nested(ImportV1ColumnSchema))
metrics = fields.List(fields.Nested(ImportV1MetricSchema))
version = fields.String(required=True)
@@ -415,6 +417,7 @@ class DatasetDrillInfoSchema(Schema):
created_on_humanized = fields.String()
changed_by = fields.Nested(UserSchema)
changed_on_humanized = fields.String()
drill_through_chart_id = fields.Integer(allow_none=True)
# pylint: disable=unused-argument
@post_dump(pass_original=True)

View File

@@ -0,0 +1,64 @@
# 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.
"""drill-through-chart-fk
Revision ID: f56ac3accfc9
Revises: cd1fb11291f2
Create Date: 2025-08-11 12:43:59.086693
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import (
add_columns,
create_fks_for_table,
drop_columns,
drop_fks_for_table,
)
# revision identifiers, used by Alembic.
revision = "f56ac3accfc9"
down_revision = "cd1fb11291f2"
def upgrade():
# Add the drill_through_chart_id column to the tables table
add_columns(
"tables", sa.Column("drill_through_chart_id", sa.Integer(), nullable=True)
)
# Create foreign key constraint to slices table
create_fks_for_table(
foreign_key_name="fk_tables_drill_through_chart_id_slices",
table_name="tables",
referenced_table="slices",
local_cols=["drill_through_chart_id"],
remote_cols=["id"],
ondelete="SET NULL",
)
def downgrade():
# Drop the foreign key constraint first
drop_fks_for_table(
table_name="tables",
foreign_key_names=["fk_tables_drill_through_chart_id_slices"],
)
# Drop the column
drop_columns("tables", "drill_through_chart_id")

View File

@@ -0,0 +1,35 @@
# 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.
"""merge heads: theme system and drill-through chart
Revision ID: 852e99567fe7
Revises: ('c233f5365c9e', 'f56ac3accfc9')
Create Date: 2025-08-21 01:54:38.918459
"""
# revision identifiers, used by Alembic.
revision = "852e99567fe7"
down_revision = ("c233f5365c9e", "f56ac3accfc9")
def upgrade():
pass
def downgrade():
pass

View File

@@ -184,6 +184,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"template_params": None,
"uuid": str(example_dataset.uuid),
"version": "1.0.0",
"drill_through_chart_id": None,
}
@patch("superset.security.manager.g")
@@ -241,6 +242,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"normalize_columns",
"always_filter_main_dttm",
"folders",
"drill_through_chart_id",
"uuid",
"metrics",
"columns",

View File

@@ -197,6 +197,7 @@ folders:
- uuid: 00000000-0000-0000-0000-000000000005
type: column
name: profit
drill_through_chart_id: null
uuid: {payload["uuid"]}
metrics:
- metric_name: cnt