mirror of
https://github.com/apache/superset.git
synced 2026-04-29 21:14:22 +00:00
Compare commits
13 Commits
engine-man
...
custom-dri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d2cee1a7 | ||
|
|
02790b750f | ||
|
|
dd3b89620e | ||
|
|
d4d02c5104 | ||
|
|
41e7f51f4b | ||
|
|
843b81c34d | ||
|
|
cf4fd3e389 | ||
|
|
abb6069d22 | ||
|
|
e05061bdb2 | ||
|
|
d8ee744365 | ||
|
|
06e64115aa | ||
|
|
56152c2366 | ||
|
|
a891ccdb10 |
28
superset-frontend/package-lock.json
generated
28
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
115
superset-frontend/src/components/Select/ChartSelect.test.tsx
Normal file
115
superset-frontend/src/components/Select/ChartSelect.test.tsx
Normal 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();
|
||||
});
|
||||
105
superset-frontend/src/components/Select/ChartSelect.tsx
Normal file
105
superset-frontend/src/components/Select/ChartSelect.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
108
superset-frontend/src/dashboard/hooks/useDashboardFormData.ts
Normal file
108
superset-frontend/src/dashboard/hooks/useDashboardFormData.ts
Normal 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,
|
||||
]);
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user