mirror of
https://github.com/apache/superset.git
synced 2026-05-06 08:24:26 +00:00
Compare commits
13 Commits
docs/testi
...
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": "^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": {
|
"node_modules/@jest/test-result": {
|
||||||
"version": "30.0.2",
|
"version": "30.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.2.tgz",
|
||||||
@@ -20795,15 +20805,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/camel-case": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||||
@@ -44945,6 +44946,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/parse-conflict-json": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz",
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ import {
|
|||||||
SaveDatasetModal,
|
SaveDatasetModal,
|
||||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||||
import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types';
|
import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types';
|
||||||
import { mountExploreUrl } from 'src/explore/exploreUtils';
|
import { generateExploreUrl } from 'src/explore/exploreUtils/formData';
|
||||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
|
||||||
import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
||||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
||||||
@@ -78,7 +77,6 @@ import {
|
|||||||
reFetchQueryResults,
|
reFetchQueryResults,
|
||||||
reRunQuery,
|
reRunQuery,
|
||||||
} from 'src/SqlLab/actions/sqlLab';
|
} from 'src/SqlLab/actions/sqlLab';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
|
||||||
import useLogAction from 'src/logger/useLogAction';
|
import useLogAction from 'src/logger/useLogAction';
|
||||||
import {
|
import {
|
||||||
LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD,
|
LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD,
|
||||||
@@ -277,16 +275,13 @@ const ResultSet = ({
|
|||||||
const openInNewWindow = clickEvent.metaKey;
|
const openInNewWindow = clickEvent.metaKey;
|
||||||
logAction(LOG_ACTIONS_SQLLAB_CREATE_CHART, {});
|
logAction(LOG_ACTIONS_SQLLAB_CREATE_CHART, {});
|
||||||
if (results?.query_id) {
|
if (results?.query_id) {
|
||||||
const key = await postFormData(results.query_id, 'query', {
|
const url = await generateExploreUrl(results.query_id, 'query', {
|
||||||
...EXPLORE_CHART_DEFAULT,
|
...EXPLORE_CHART_DEFAULT,
|
||||||
datasource: `${results.query_id}__query`,
|
datasource: `${results.query_id}__query`,
|
||||||
...{
|
...{
|
||||||
all_columns: results.columns.map(column => column.column_name),
|
all_columns: results.columns.map(column => column.column_name),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const url = mountExploreUrl(null, {
|
|
||||||
[URL_PARAMS.formDataKey.name]: key,
|
|
||||||
});
|
|
||||||
if (openInNewWindow) {
|
if (openInNewWindow) {
|
||||||
window.open(url, '_blank', 'noreferrer');
|
window.open(url, '_blank', 'noreferrer');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { RootState } from 'src/dashboard/types';
|
import { RootState } from 'src/dashboard/types';
|
||||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
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 { simpleFilterToAdhoc } from 'src/utils/simpleFilterToAdhoc';
|
||||||
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
|
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
|
||||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||||
@@ -96,11 +96,12 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// short circuit if the user is embedded as explore is not available
|
// short circuit if the user is embedded as explore is not available
|
||||||
if (isEmbedded()) return;
|
if (isEmbedded()) return;
|
||||||
postFormData(Number(datasource_id), datasource_type, formData, 0)
|
generateExploreUrl(Number(datasource_id), datasource_type, formData, {
|
||||||
.then(key => {
|
chartId: 0,
|
||||||
setUrl(
|
dashboardPageId,
|
||||||
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,
|
})
|
||||||
);
|
.then(url => {
|
||||||
|
setUrl(url);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
addDangerToast(t('Failed to generate chart edit URL'));
|
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 { id: chartId, form_data: formData } = chartQueries[sliceId];
|
||||||
const { slice_name: chartName } = formData;
|
const { slice_name: chartName } = formData;
|
||||||
const store = getMockStoreWithNativeFilters();
|
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 DrillDetailModalWrapper = () => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +68,7 @@ const renderModal = async (overrideState: Record<string, any> = {}) => {
|
|||||||
initialFilters={[]}
|
initialFilters={[]}
|
||||||
showModal={showModal}
|
showModal={showModal}
|
||||||
onHideModal={() => setShowModal(false)}
|
onHideModal={() => setShowModal(false)}
|
||||||
|
dataset={dataset}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -80,11 +92,21 @@ test('should render the title', async () => {
|
|||||||
expect(screen.getByText(`Drill to detail: ${chartName}`)).toBeInTheDocument();
|
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();
|
await renderModal();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'Edit chart' }),
|
screen.queryByRole('button', { name: 'Explore' }),
|
||||||
).toBeInTheDocument();
|
).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);
|
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,20 +117,19 @@ test('should close the modal', async () => {
|
|||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should forward to Explore', async () => {
|
test('should render "Explore" as disabled without can_explore permission', async () => {
|
||||||
await renderModal();
|
const datasetWithDrillThrough = {
|
||||||
userEvent.click(screen.getByRole('button', { name: 'Edit chart' }));
|
drill_through_chart_id: 123,
|
||||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
id: 456, // Required for URL generation
|
||||||
`/explore/?dashboard_page_id=&slice_id=${sliceId}`,
|
};
|
||||||
);
|
await renderModal(
|
||||||
});
|
{
|
||||||
|
user: {
|
||||||
test('should render "Edit chart" as disabled without can_explore permission', async () => {
|
...drillToDetailModalState.user,
|
||||||
await renderModal({
|
roles: { Admin: [['invalid_permission', 'Superset']] },
|
||||||
user: {
|
},
|
||||||
...drillToDetailModalState.user,
|
|
||||||
roles: { Admin: [['invalid_permission', 'Superset']] },
|
|
||||||
},
|
},
|
||||||
});
|
datasetWithDrillThrough,
|
||||||
expect(screen.getByRole('button', { name: 'Edit chart' })).toBeDisabled();
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Explore' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
BinaryQueryObjectFilterClause,
|
BinaryQueryObjectFilterClause,
|
||||||
css,
|
css,
|
||||||
@@ -33,37 +32,46 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
|||||||
import { Slice } from 'src/types/Chart';
|
import { Slice } from 'src/types/Chart';
|
||||||
import { RootState } from 'src/dashboard/types';
|
import { RootState } from 'src/dashboard/types';
|
||||||
import { findPermission } from 'src/utils/findPermission';
|
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 { Dataset } from '../types';
|
||||||
import DrillDetailPane from './DrillDetailPane';
|
import DrillDetailPane from './DrillDetailPane';
|
||||||
|
|
||||||
interface ModalFooterProps {
|
interface ModalFooterProps {
|
||||||
canExplore: boolean;
|
canExplore: boolean;
|
||||||
closeModal?: () => void;
|
closeModal?: () => void;
|
||||||
exploreChart: () => void;
|
showEditButton: boolean;
|
||||||
|
onExploreClick?: (event: React.MouseEvent) => void;
|
||||||
|
isGeneratingUrl: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalFooter = ({
|
const ModalFooter = ({
|
||||||
canExplore,
|
canExplore,
|
||||||
closeModal,
|
closeModal,
|
||||||
exploreChart,
|
showEditButton,
|
||||||
|
onExploreClick,
|
||||||
|
isGeneratingUrl,
|
||||||
}: ModalFooterProps) => {
|
}: ModalFooterProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isEmbedded() && (
|
{!isEmbedded() && showEditButton && (
|
||||||
<Button
|
<Button
|
||||||
buttonStyle="secondary"
|
buttonStyle="secondary"
|
||||||
buttonSize="small"
|
buttonSize="small"
|
||||||
onClick={exploreChart}
|
onClick={canExplore ? onExploreClick : undefined}
|
||||||
disabled={!canExplore}
|
disabled={!canExplore || isGeneratingUrl}
|
||||||
|
loading={isGeneratingUrl}
|
||||||
tooltip={
|
tooltip={
|
||||||
!canExplore
|
!canExplore
|
||||||
? t('You do not have sufficient permissions to edit the chart')
|
? t('You do not have sufficient permissions to explore the chart')
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('Edit chart')}
|
{t('Explore')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -99,8 +107,10 @@ export default function DrillDetailModal({
|
|||||||
dataset,
|
dataset,
|
||||||
}: DrillDetailModalProps) {
|
}: DrillDetailModalProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const history = useHistory();
|
|
||||||
const dashboardPageId = useContext(DashboardPageIdContext);
|
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||||
|
const { addDangerToast } = useToasts();
|
||||||
|
const [isGeneratingUrl, setIsGeneratingUrl] = useState(false);
|
||||||
|
|
||||||
const { slice_name: chartName } = useSelector(
|
const { slice_name: chartName } = useSelector(
|
||||||
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
|
(state: { sliceEntities: { slices: Record<number, Slice> } }) =>
|
||||||
state.sliceEntities?.slices?.[chartId] || {},
|
state.sliceEntities?.slices?.[chartId] || {},
|
||||||
@@ -109,14 +119,65 @@ export default function DrillDetailModal({
|
|||||||
findPermission('can_explore', 'Superset', state.user?.roles),
|
findPermission('can_explore', 'Superset', state.user?.roles),
|
||||||
);
|
);
|
||||||
|
|
||||||
const exploreUrl = useMemo(
|
const showEditButton = Boolean(dataset?.drill_through_chart_id);
|
||||||
() => `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${chartId}`,
|
const dashboardContextFormData = useDashboardFormData(
|
||||||
[chartId, dashboardPageId],
|
dataset?.drill_through_chart_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const exploreChart = useCallback(() => {
|
const drillThroughFormData = useMemo(() => {
|
||||||
history.push(exploreUrl);
|
if (!dataset?.drill_through_chart_id || !dataset?.id) {
|
||||||
}, [exploreUrl, history]);
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -131,7 +192,12 @@ export default function DrillDetailModal({
|
|||||||
name={t('Drill to detail: %s', chartName)}
|
name={t('Drill to detail: %s', chartName)}
|
||||||
title={t('Drill to detail: %s', chartName)}
|
title={t('Drill to detail: %s', chartName)}
|
||||||
footer={
|
footer={
|
||||||
<ModalFooter exploreChart={exploreChart} canExplore={canExplore} />
|
<ModalFooter
|
||||||
|
canExplore={canExplore}
|
||||||
|
showEditButton={showEditButton}
|
||||||
|
onExploreClick={handleExploreClick}
|
||||||
|
isGeneratingUrl={isGeneratingUrl}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
responsive
|
responsive
|
||||||
resizable
|
resizable
|
||||||
@@ -151,6 +217,7 @@ export default function DrillDetailModal({
|
|||||||
formData={formData}
|
formData={formData}
|
||||||
initialFilters={initialFilters}
|
initialFilters={initialFilters}
|
||||||
dataset={dataset}
|
dataset={dataset}
|
||||||
|
drillThroughFormData={drillThroughFormData}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
GenericDataType,
|
GenericDataType,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
|
StatefulChart,
|
||||||
t,
|
t,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@superset-ui/core';
|
} 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 BooleanCell from '@superset-ui/core/components/Table/cell-renderers/BooleanCell';
|
||||||
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
||||||
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
|
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 { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||||
import Table, {
|
import Table, {
|
||||||
ColumnsType,
|
ColumnsType,
|
||||||
@@ -81,10 +82,12 @@ export default function DrillDetailPane({
|
|||||||
formData,
|
formData,
|
||||||
initialFilters,
|
initialFilters,
|
||||||
dataset,
|
dataset,
|
||||||
|
drillThroughFormData,
|
||||||
}: {
|
}: {
|
||||||
formData: QueryFormData;
|
formData: QueryFormData;
|
||||||
initialFilters: BinaryQueryObjectFilterClause[];
|
initialFilters: BinaryQueryObjectFilterClause[];
|
||||||
dataset?: Dataset;
|
dataset?: Dataset;
|
||||||
|
drillThroughFormData?: QueryFormData | null;
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
@@ -161,7 +164,7 @@ export default function DrillDetailPane({
|
|||||||
) : (
|
) : (
|
||||||
dataset?.verbose_map?.[column] || column
|
dataset?.verbose_map?.[column] || column
|
||||||
),
|
),
|
||||||
render: value => {
|
render: (value: any) => {
|
||||||
if (value === true || value === false) {
|
if (value === true || value === false) {
|
||||||
return <BooleanCell value={value} />;
|
return <BooleanCell value={value} />;
|
||||||
}
|
}
|
||||||
@@ -233,6 +236,10 @@ export default function DrillDetailPane({
|
|||||||
|
|
||||||
// Download page of results & trim cache if page not in cache
|
// Download page of results & trim cache if page not in cache
|
||||||
useEffect(() => {
|
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)) {
|
if (!responseError && !isLoading && !resultsPages.has(pageIndex)) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const jsonPayload = getDrillPayload(formData, filters) ?? {};
|
const jsonPayload = getDrillPayload(formData, filters) ?? {};
|
||||||
@@ -282,12 +289,27 @@ export default function DrillDetailPane({
|
|||||||
resultsPages,
|
resultsPages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bootstrapping = !responseError && !resultsPages.size;
|
const bootstrapping =
|
||||||
|
!dataset?.drill_through_chart_id && !responseError && !resultsPages.size;
|
||||||
|
|
||||||
const allowHTML = formData.allow_render_html ?? true;
|
const allowHTML = formData.allow_render_html ?? true;
|
||||||
|
|
||||||
let tableContent = null;
|
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
|
// Render error if page download failed
|
||||||
tableContent = (
|
tableContent = (
|
||||||
<pre
|
<pre
|
||||||
@@ -331,7 +353,7 @@ export default function DrillDetailPane({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!bootstrapping && metadataBarComponent}
|
{!bootstrapping && metadataBarComponent}
|
||||||
{!bootstrapping && (
|
{!bootstrapping && !dataset?.drill_through_chart_id && (
|
||||||
<TableControls
|
<TableControls
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export enum DrillByType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Dataset = {
|
export type Dataset = {
|
||||||
|
id?: number;
|
||||||
changed_by?: {
|
changed_by?: {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
@@ -44,4 +45,5 @@ export type Dataset = {
|
|||||||
drillable_columns?: Column[];
|
drillable_columns?: Column[];
|
||||||
metrics?: Metric[];
|
metrics?: Metric[];
|
||||||
verbose_map?: Record<string, string>;
|
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 { ErrorMessageWithStackTrace } from 'src/components';
|
||||||
import type { DatasetObject } from 'src/features/datasets/types';
|
import type { DatasetObject } from 'src/features/datasets/types';
|
||||||
import type { DatasourceModalProps } from '../types';
|
import type { DatasourceModalProps } from '../types';
|
||||||
|
import { invalidateDatasetDrillCache } from 'src/utils/cachedSupersetGet';
|
||||||
|
|
||||||
const DatasourceEditor = AsyncEsmComponent(
|
const DatasourceEditor = AsyncEsmComponent(
|
||||||
() => import('../components/DatasourceEditor'),
|
() => import('../components/DatasourceEditor'),
|
||||||
@@ -181,6 +182,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
owners: datasource.owners.map(
|
owners: datasource.owners.map(
|
||||||
(o: Record<string, number>) => o.value || o.id,
|
(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
|
// Handle catalog based on database's allow_multi_catalog setting
|
||||||
// If multi-catalog is disabled, don't include catalog in payload
|
// If multi-catalog is disabled, don't include catalog in payload
|
||||||
@@ -203,6 +205,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
const { json } = await SupersetClient.get({
|
const { json } = await SupersetClient.get({
|
||||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
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'));
|
addSuccessToast(t('The dataset has been saved'));
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
json.result.type = 'table';
|
json.result.type = 'table';
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import Fieldset from '../Fieldset';
|
|||||||
import Field from '../Field';
|
import Field from '../Field';
|
||||||
import { fetchSyncedColumns, updateColumns } from '../../utils';
|
import { fetchSyncedColumns, updateColumns } from '../../utils';
|
||||||
import DatasetUsageTab from './components/DatasetUsageTab';
|
import DatasetUsageTab from './components/DatasetUsageTab';
|
||||||
|
import ChartSelect from '../Select/ChartSelect';
|
||||||
|
|
||||||
const extensionsRegistry = getExtensionsRegistry();
|
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 && (
|
{this.state.isSqla && (
|
||||||
<Field
|
<Field
|
||||||
fieldKey="extra"
|
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,
|
NativeFilterScope,
|
||||||
NativeFiltersState,
|
NativeFiltersState,
|
||||||
NativeFilterTarget,
|
NativeFilterTarget,
|
||||||
|
QueryFormData,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { Dataset } from '@superset-ui/chart-controls';
|
import { Dataset } from '@superset-ui/chart-controls';
|
||||||
import { chart } from 'src/components/Chart/chartReducer';
|
import { chart } from 'src/components/Chart/chartReducer';
|
||||||
@@ -299,3 +300,31 @@ export enum MenuKeys {
|
|||||||
ManageEmailReports = 'manage_email_reports',
|
ManageEmailReports = 'manage_email_reports',
|
||||||
ExportPivotXlsx = 'export_pivot_xlsx',
|
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 { isEqual } from 'lodash';
|
||||||
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
import getEffectiveExtraFilters from './getEffectiveExtraFilters';
|
||||||
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
import { getAllActiveFilters } from '../activeAllDashboardFilters';
|
||||||
|
import { getFilterIdsAppliedOnChart } from '../getFilterIdsAppliedOnChart';
|
||||||
|
|
||||||
interface CachedFormData {
|
interface CachedFormData {
|
||||||
extra_form_data?: JsonObject;
|
extra_form_data?: JsonObject;
|
||||||
@@ -157,9 +158,10 @@ export default function getFormDataWithExtraFilters({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let extraData: JsonObject = {};
|
let extraData: JsonObject = {};
|
||||||
const filterIdsAppliedOnChart = Object.entries(activeFilters)
|
const filterIdsAppliedOnChart = getFilterIdsAppliedOnChart(
|
||||||
.filter(([, activeFilter]) => activeFilter.scope.includes(chart.id))
|
activeFilters,
|
||||||
.map(([filterId]) => filterId);
|
chart.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (filterIdsAppliedOnChart.length) {
|
if (filterIdsAppliedOnChart.length) {
|
||||||
const aggregatedFormData = getExtraFormData(
|
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'));
|
userEvent.click(await screen.findByText('onChange'));
|
||||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
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;
|
) => SelectOptionsType;
|
||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
onChange: (val: SelectValue) => void;
|
onChange: (val: SelectValue) => void;
|
||||||
|
// Optional search parameters to append to the endpoint
|
||||||
|
searchParams?: Record<string, any>;
|
||||||
// ControlHeader related props
|
// ControlHeader related props
|
||||||
description?: string;
|
description?: string;
|
||||||
hovered?: boolean;
|
hovered?: boolean;
|
||||||
@@ -48,7 +50,7 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLabeledValue(arg: any): arg is LabeledValue {
|
function isLabeledValue(arg: any): arg is LabeledValue {
|
||||||
return arg.value !== undefined;
|
return arg && typeof arg === 'object' && arg.value !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectAsyncControl = ({
|
const SelectAsyncControl = ({
|
||||||
@@ -60,6 +62,7 @@ const SelectAsyncControl = ({
|
|||||||
mutator,
|
mutator,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
searchParams,
|
||||||
value,
|
value,
|
||||||
...props
|
...props
|
||||||
}: SelectAsyncControlProps) => {
|
}: SelectAsyncControlProps) => {
|
||||||
@@ -98,6 +101,7 @@ const SelectAsyncControl = ({
|
|||||||
const loadOptions = () =>
|
const loadOptions = () =>
|
||||||
SupersetClient.get({
|
SupersetClient.get({
|
||||||
endpoint: dataEndpoint,
|
endpoint: dataEndpoint,
|
||||||
|
searchParams,
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const data = mutator
|
const data = mutator
|
||||||
@@ -113,7 +117,7 @@ const SelectAsyncControl = ({
|
|||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}
|
}
|
||||||
}, [addDangerToast, dataEndpoint, mutator, value, loaded]);
|
}, [addDangerToast, dataEndpoint, mutator, value, loaded, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AdhocFilter,
|
AdhocFilter,
|
||||||
|
BinaryQueryObjectFilterClause,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
|
EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS,
|
||||||
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
|
EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS,
|
||||||
@@ -195,6 +196,7 @@ export const getFormDataWithDashboardContext = (
|
|||||||
exploreFormData: QueryFormData,
|
exploreFormData: QueryFormData,
|
||||||
dashboardContextFormData: JsonObject,
|
dashboardContextFormData: JsonObject,
|
||||||
saveAction?: string | null,
|
saveAction?: string | null,
|
||||||
|
drillToDetailFilters?: BinaryQueryObjectFilterClause[],
|
||||||
) => {
|
) => {
|
||||||
const filterBoxData = mergeFilterBoxToFormData(
|
const filterBoxData = mergeFilterBoxToFormData(
|
||||||
exploreFormData,
|
exploreFormData,
|
||||||
@@ -205,6 +207,15 @@ export const getFormDataWithDashboardContext = (
|
|||||||
exploreFormData,
|
exploreFormData,
|
||||||
dashboardContextFormData,
|
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 =
|
const isDeckGLChart =
|
||||||
exploreFormData.viz_type === 'deck_multi' ||
|
exploreFormData.viz_type === 'deck_multi' ||
|
||||||
dashboardContextFormData.viz_type === 'deck_multi';
|
dashboardContextFormData.viz_type === 'deck_multi';
|
||||||
@@ -215,6 +226,7 @@ export const getFormDataWithDashboardContext = (
|
|||||||
...Object.keys(exploreFormData),
|
...Object.keys(exploreFormData),
|
||||||
...Object.keys(filterBoxData),
|
...Object.keys(filterBoxData),
|
||||||
...Object.keys(nativeFiltersData),
|
...Object.keys(nativeFiltersData),
|
||||||
|
...Object.keys(drillToDetailFiltersData),
|
||||||
]
|
]
|
||||||
.filter(key => key.match(/adhoc_filter.*/))
|
.filter(key => key.match(/adhoc_filter.*/))
|
||||||
.reduce(
|
.reduce(
|
||||||
@@ -225,6 +237,7 @@ export const getFormDataWithDashboardContext = (
|
|||||||
...ensureIsArray(exploreFormData[key]),
|
...ensureIsArray(exploreFormData[key]),
|
||||||
...ensureIsArray(filterBoxData[key]),
|
...ensureIsArray(filterBoxData[key]),
|
||||||
...ensureIsArray(nativeFiltersData[key]),
|
...ensureIsArray(nativeFiltersData[key]),
|
||||||
|
...ensureIsArray(drillToDetailFiltersData[key]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const afterDuplicates = removeAdhocFilterDuplicates(beforeDuplicates);
|
const afterDuplicates = removeAdhocFilterDuplicates(beforeDuplicates);
|
||||||
@@ -280,6 +293,7 @@ export const getFormDataWithDashboardContext = (
|
|||||||
...dashboardContextFormData,
|
...dashboardContextFormData,
|
||||||
...filterBoxData,
|
...filterBoxData,
|
||||||
...nativeFiltersData,
|
...nativeFiltersData,
|
||||||
|
...drillToDetailFiltersData,
|
||||||
...adhocFilters,
|
...adhocFilters,
|
||||||
...exploreFormData, // Explore form data comes last to override
|
...exploreFormData, // Explore form data comes last to override
|
||||||
own_color_scheme: ownColorScheme,
|
own_color_scheme: ownColorScheme,
|
||||||
@@ -296,6 +310,7 @@ export const getFormDataWithDashboardContext = (
|
|||||||
...dashboardContextFormData,
|
...dashboardContextFormData,
|
||||||
...filterBoxData,
|
...filterBoxData,
|
||||||
...nativeFiltersData,
|
...nativeFiltersData,
|
||||||
|
...drillToDetailFiltersData,
|
||||||
...adhocFilters,
|
...adhocFilters,
|
||||||
own_color_scheme: ownColorScheme,
|
own_color_scheme: ownColorScheme,
|
||||||
color_scheme: appliedColorScheme,
|
color_scheme: appliedColorScheme,
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
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', () => ({
|
jest.mock('@superset-ui/core', () => ({
|
||||||
SupersetClient: {
|
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 () => {
|
test('postFormData should call SupersetClient.post with correct payload and return key', async () => {
|
||||||
const mockKey = '123abc';
|
const mockKey = '123abc';
|
||||||
const mockResponse = { json: { key: mockKey } };
|
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 { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core';
|
||||||
import { sanitizeFormData } from 'src/utils/sanitizeFormData';
|
import { sanitizeFormData } from 'src/utils/sanitizeFormData';
|
||||||
|
import { URL_PARAMS } from 'src/constants';
|
||||||
|
import { mountExploreUrl } from './index';
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
datasource_id: number;
|
datasource_id: number;
|
||||||
@@ -86,3 +88,48 @@ export const putFormData = (
|
|||||||
chartId,
|
chartId,
|
||||||
),
|
),
|
||||||
}).then((r: JsonResponse) => r.json.message);
|
}).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' });
|
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
|
||||||
userEvent.click(dropdownTrigger);
|
userEvent.click(dropdownTrigger);
|
||||||
|
|
||||||
// Check that the dropdown menu option is visible
|
// Check that the dropdown menu option is in the document
|
||||||
await waitFor(() => {
|
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>;
|
column_formats: Record<string, string>;
|
||||||
datasource_name: string | null;
|
datasource_name: string | null;
|
||||||
verbose_map: Record<string, string>;
|
verbose_map: Record<string, string>;
|
||||||
|
drill_through_chart_id?: number | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,3 +27,20 @@ export const cachedSupersetGet = cacheWrapper(
|
|||||||
supersetGetCache,
|
supersetGetCache,
|
||||||
({ endpoint }) => endpoint || '',
|
({ 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)
|
always_filter_main_dttm = Column(Boolean, default=False)
|
||||||
folders = Column(JSON, nullable=True)
|
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"
|
baselink = "tablemodelview"
|
||||||
|
|
||||||
export_fields = [
|
export_fields = [
|
||||||
@@ -1169,6 +1179,7 @@ class SqlaTable(
|
|||||||
"normalize_columns",
|
"normalize_columns",
|
||||||
"always_filter_main_dttm",
|
"always_filter_main_dttm",
|
||||||
"folders",
|
"folders",
|
||||||
|
"drill_through_chart_id",
|
||||||
]
|
]
|
||||||
update_from_object_fields = [f for f in export_fields if f != "database_id"]
|
update_from_object_fields = [f for f in export_fields if f != "database_id"]
|
||||||
export_parent = "database"
|
export_parent = "database"
|
||||||
@@ -1365,6 +1376,7 @@ class SqlaTable(
|
|||||||
data_["owners"] = self.owners_data
|
data_["owners"] = self.owners_data
|
||||||
data_["always_filter_main_dttm"] = self.always_filter_main_dttm
|
data_["always_filter_main_dttm"] = self.always_filter_main_dttm
|
||||||
data_["normalize_columns"] = self.normalize_columns
|
data_["normalize_columns"] = self.normalize_columns
|
||||||
|
data_["drill_through_chart_id"] = self.drill_through_chart_id
|
||||||
return data_
|
return data_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"changed_on_humanized",
|
"changed_on_humanized",
|
||||||
"changed_by.first_name",
|
"changed_by.first_name",
|
||||||
"changed_by.last_name",
|
"changed_by.last_name",
|
||||||
|
"drill_through_chart_id",
|
||||||
]
|
]
|
||||||
show_columns = show_select_columns + [
|
show_columns = show_select_columns + [
|
||||||
"columns.type_generic",
|
"columns.type_generic",
|
||||||
@@ -236,6 +237,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"time_grain_sqla",
|
"time_grain_sqla",
|
||||||
"order_by_choices",
|
"order_by_choices",
|
||||||
"verbose_map",
|
"verbose_map",
|
||||||
|
"drill_through_chart_id",
|
||||||
]
|
]
|
||||||
add_model_schema = DatasetPostSchema()
|
add_model_schema = DatasetPostSchema()
|
||||||
edit_model_schema = DatasetPutSchema()
|
edit_model_schema = DatasetPutSchema()
|
||||||
@@ -261,6 +263,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"columns",
|
"columns",
|
||||||
"metrics",
|
"metrics",
|
||||||
"extra",
|
"extra",
|
||||||
|
"drill_through_chart_id",
|
||||||
]
|
]
|
||||||
openapi_spec_tag = "Datasets"
|
openapi_spec_tag = "Datasets"
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ class DatasetPutSchema(Schema):
|
|||||||
columns = fields.List(fields.Nested(DatasetColumnsPutSchema))
|
columns = fields.List(fields.Nested(DatasetColumnsPutSchema))
|
||||||
metrics = fields.List(fields.Nested(DatasetMetricsPutSchema))
|
metrics = fields.List(fields.Nested(DatasetMetricsPutSchema))
|
||||||
folders = fields.List(fields.Nested(FolderSchema), required=False)
|
folders = fields.List(fields.Nested(FolderSchema), required=False)
|
||||||
|
drill_through_chart_id = fields.Integer(allow_none=True)
|
||||||
extra = fields.String(allow_none=True)
|
extra = fields.String(allow_none=True)
|
||||||
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
|
is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
|
||||||
external_url = fields.String(allow_none=True)
|
external_url = fields.String(allow_none=True)
|
||||||
@@ -322,6 +323,7 @@ class ImportV1DatasetSchema(Schema):
|
|||||||
fetch_values_predicate = fields.String(allow_none=True)
|
fetch_values_predicate = fields.String(allow_none=True)
|
||||||
extra = fields.Dict(allow_none=True)
|
extra = fields.Dict(allow_none=True)
|
||||||
uuid = fields.UUID(required=True)
|
uuid = fields.UUID(required=True)
|
||||||
|
drill_through_chart_id = fields.Integer(allow_none=True)
|
||||||
columns = fields.List(fields.Nested(ImportV1ColumnSchema))
|
columns = fields.List(fields.Nested(ImportV1ColumnSchema))
|
||||||
metrics = fields.List(fields.Nested(ImportV1MetricSchema))
|
metrics = fields.List(fields.Nested(ImportV1MetricSchema))
|
||||||
version = fields.String(required=True)
|
version = fields.String(required=True)
|
||||||
@@ -415,6 +417,7 @@ class DatasetDrillInfoSchema(Schema):
|
|||||||
created_on_humanized = fields.String()
|
created_on_humanized = fields.String()
|
||||||
changed_by = fields.Nested(UserSchema)
|
changed_by = fields.Nested(UserSchema)
|
||||||
changed_on_humanized = fields.String()
|
changed_on_humanized = fields.String()
|
||||||
|
drill_through_chart_id = fields.Integer(allow_none=True)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
@post_dump(pass_original=True)
|
@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,
|
"template_params": None,
|
||||||
"uuid": str(example_dataset.uuid),
|
"uuid": str(example_dataset.uuid),
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"drill_through_chart_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("superset.security.manager.g")
|
@patch("superset.security.manager.g")
|
||||||
@@ -241,6 +242,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
|||||||
"normalize_columns",
|
"normalize_columns",
|
||||||
"always_filter_main_dttm",
|
"always_filter_main_dttm",
|
||||||
"folders",
|
"folders",
|
||||||
|
"drill_through_chart_id",
|
||||||
"uuid",
|
"uuid",
|
||||||
"metrics",
|
"metrics",
|
||||||
"columns",
|
"columns",
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ folders:
|
|||||||
- uuid: 00000000-0000-0000-0000-000000000005
|
- uuid: 00000000-0000-0000-0000-000000000005
|
||||||
type: column
|
type: column
|
||||||
name: profit
|
name: profit
|
||||||
|
drill_through_chart_id: null
|
||||||
uuid: {payload["uuid"]}
|
uuid: {payload["uuid"]}
|
||||||
metrics:
|
metrics:
|
||||||
- metric_name: cnt
|
- metric_name: cnt
|
||||||
|
|||||||
Reference in New Issue
Block a user