feat: Enable drilling in embedded (#34319)

This commit is contained in:
Vitor Avila
2025-08-05 02:23:00 -03:00
committed by GitHub
parent 791ea9860d
commit 49689eec6c
29 changed files with 1510 additions and 336 deletions

View File

@@ -31,6 +31,52 @@ import {
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
@@ -62,6 +108,7 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
@@ -235,12 +282,14 @@ describe('Drill by modal', () => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
@@ -389,6 +438,7 @@ describe('Drill by modal', () => {
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
@@ -552,6 +602,7 @@ describe('Drill by modal', () => {
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});

View File

@@ -22,7 +22,9 @@ import {
ReactNode,
RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import ReactDOM from 'react-dom';
@@ -35,7 +37,9 @@ import {
ensureIsArray,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
isFeatureEnabled,
logging,
QueryFormData,
t,
useTheme,
@@ -48,6 +52,10 @@ import { updateDataMask } from 'src/dataMask/actions';
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { Dataset } from 'src/components/Chart/types';
import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
@@ -99,6 +107,9 @@ const ChartContextMenu = (
const crossFiltersEnabled = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const [openKeys, setOpenKeys] = useState<Key[]>([]);
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
@@ -121,6 +132,7 @@ const ChartContextMenu = (
const [drillByColumn, setDrillByColumn] = useState<Column>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const [dataset, setDataset] = useState<Dataset>();
const [isLoadingDataset, setIsLoadingDataset] = useState(false);
const verboseMap = useVerboseMap(dataset);
const closeContextMenu = useCallback(() => {
@@ -129,12 +141,15 @@ const ChartContextMenu = (
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
const handleDrillBy = useCallback((column: Column) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
setShowDrillByModal(true);
}, []);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
);
const handleCloseDrillByModal = useCallback(() => {
setShowDrillByModal(false);
}, []);
@@ -151,6 +166,75 @@ const ChartContextMenu = (
canDrillBy &&
isDisplayed(ContextMenuItem.DrillBy);
useEffect(() => {
async function fetchDataset() {
if (!visible || dataset || (!showDrillBy && !showDrillToDetail)) return;
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingDataset(true);
let response;
if (loadDrillByOptionsExtension) {
response = await loadDrillByOptionsExtension(datasetId, formData);
} else {
const endpoint = `/api/v1/dataset/${datasetId}/drill_info/?q=(dashboard_id:${dashboardId})`;
response = await cachedSupersetGet({ endpoint });
}
const { json } = response;
const { result } = json;
setDataset(result);
} catch (error) {
logging.error('Failed to load dataset:', error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}/drill_info/`);
} finally {
setIsLoadingDataset(false);
}
}
fetchDataset();
}, [
visible,
showDrillBy,
showDrillToDetail,
formData.datasource,
loadDrillByOptionsExtension,
dashboardId,
]);
// Compute filteredDataset with all columns returned + a filtered list of valid drillable options
const filteredDataset = useMemo(() => {
if (!dataset || !showDrillBy) return dataset;
const filteredColumns = ensureIsArray(dataset.columns).filter(
column =>
// If using an extension, also filter by column.groupby since the extension might not do this
(!loadDrillByOptionsExtension || column.groupby) &&
!ensureIsArray(
formData[filters?.drillBy?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(additionalConfig?.drillBy?.excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
);
return {
...dataset,
drillable_columns: filteredColumns,
};
}, [
dataset,
showDrillBy,
filters?.drillBy?.groupbyFieldName,
formData.x_axis,
formData[filters?.drillBy?.groupbyFieldName ?? ''],
additionalConfig?.drillBy?.excludedColumns,
loadDrillByOptionsExtension,
]);
const showCrossFilters = isDisplayed(ContextMenuItem.CrossFilter);
const isCrossFilteringSupportedByChart = getChartMetadataRegistry()
@@ -254,6 +338,8 @@ const ChartContextMenu = (
onSelection={onSelection}
submenuIndex={showCrossFilters ? 2 : 1}
setShowModal={setDrillModalIsOpen}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillToDetail || {})}
/>,
);
@@ -277,6 +363,8 @@ const ChartContextMenu = (
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
onDrillBy={handleDrillBy}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillBy || {})}
/>,
);
@@ -359,6 +447,7 @@ const ChartContextMenu = (
onHideModal={() => {
setDrillModalIsOpen(false);
}}
dataset={filteredDataset}
/>
)}
{showDrillByModal && drillByColumn && dataset && filters?.drillBy && (
@@ -367,7 +456,7 @@ const ChartContextMenu = (
drillByConfig={filters?.drillBy}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
dataset={{ ...filteredDataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}

View File

@@ -64,6 +64,7 @@ const setup = ({
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
['can_write', 'ExploreFormDataRestApi'],
['can_get_drill_info', 'Dataset'],
],
},
},
@@ -92,12 +93,13 @@ test('Context menu contains all displayed items only', () => {
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_explore` & `can_write` perms', () => {
test('Context menu shows "Drill by" with `can_drill`, `can_write` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -105,26 +107,14 @@ test('Context menu shows "Drill by" with `can_explore` & `can_write` perms', ()
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_drill` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_drill` & `can_explore` + `can_write` perms', () => {
test('Context menu shows "Drill by" with `can_drill`, `can_get_drill_info` & `can_explore` + `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -152,12 +142,40 @@ test('Context menu does not show "Drill by" with just `can_dril` perm', () => {
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_samples` and `can_explore` perms', () => {
test('Context menu does not show "Drill by" with just `can_dril` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_drill', 'Dashboard'],
['can_write', 'ExploreFormDataRestApi'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill by" with just `can_drill`, `can_explore` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_samples`, `can_explore` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -165,12 +183,13 @@ test('Context menu shows "Drill to detail" with `can_samples` and `can_explore`
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_drill` & `can_samples` perms', () => {
test('Context menu shows "Drill to detail" with `can_drill`, `can_samples` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -178,13 +197,14 @@ test('Context menu shows "Drill to detail" with `can_drill` & `can_samples` perm
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_drill` & `can_explore` + `can_write` perms', () => {
test('Context menu shows "Drill to detail" with `can_drill`, `can_get_drill_info` & `can_explore` + `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -202,7 +222,7 @@ test('Context menu does not show "Drill to detail" with neither of required perm
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with just `can_dril` perm', () => {
test('Context menu does not show "Drill to detail" with just `can_drill` perm', () => {
const result = setup({
roles: {
Admin: [['can_drill', 'Dashboard']],
@@ -211,3 +231,43 @@ test('Context menu does not show "Drill to detail" with just `can_dril` perm', (
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with just `can_drill` & `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_drill', 'Dashboard'],
['can_samples', 'Datasource'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with `can_samples` & `can_explore` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with `can_drill`, `can_explore` + `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});

View File

@@ -36,9 +36,6 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7*';
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
const { form_data: defaultFormData } = chartQueries[sliceId];
jest.mock('lodash/debounce', () => (fn: Function & { debounce: Function }) => {
@@ -61,6 +58,19 @@ const defaultColumns = [
{ column_name: 'col11', groupby: true },
];
const mockDataset = {
id: 7,
table_name: 'test_table',
columns: defaultColumns,
drillable_columns: defaultColumns,
changed_on_humanized: '1 day ago',
created_on_humanized: '2 days ago',
description: 'Test dataset',
owners: [],
changed_by: { first_name: 'Test', last_name: 'User' },
created_by: { first_name: 'Test', last_name: 'User' },
};
const defaultFilters = [
{
col: 'filter_col',
@@ -72,6 +82,7 @@ const defaultFilters = [
const renderMenu = ({
formData = defaultFormData,
drillByConfig = { filters: defaultFilters, groupbyFieldName: 'groupby' },
dataset = mockDataset,
...rest
}: Partial<DrillByMenuItemsProps>) =>
render(
@@ -79,6 +90,7 @@ const renderMenu = ({
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
dataset={dataset}
open
{...rest}
/>
@@ -151,20 +163,20 @@ test('render enabled menu item for supported chart, no filters', async () => {
});
test('render disabled menu item for supported chart, no columns', async () => {
fetchMock.get(DATASET_ENDPOINT, { result: { columns: [] } });
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
const emptyDataset = { ...mockDataset, columns: [], drillable_columns: [] };
renderMenu({ dataset: emptyDataset });
await expectDrillByEnabled();
screen.getByText('No columns found');
});
test('render menu item with submenu without searchbox', async () => {
const slicedColumns = defaultColumns.slice(0, 9);
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: slicedColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
const datasetWithSlicedColumns = {
...mockDataset,
columns: slicedColumns,
drillable_columns: slicedColumns,
};
renderMenu({ dataset: datasetWithSlicedColumns });
await expectDrillByEnabled();
// Check that each column appears in the drill-by submenu
@@ -180,11 +192,7 @@ test('render menu item with submenu without searchbox', async () => {
jest.setTimeout(20000);
test('render menu item with submenu and searchbox', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
renderMenu({ dataset: mockDataset });
await expectDrillByEnabled();
// Wait for all columns to be visible
@@ -236,18 +244,21 @@ test('render menu item with submenu and searchbox', async () => {
});
test('Do not display excluded column in the menu', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
});
const excludedColNames = ['col3', 'col5'];
const filteredColumns = defaultColumns.filter(
col => !excludedColNames.includes(col.column_name),
);
const datasetWithFilteredColumns = {
...mockDataset,
drillable_columns: filteredColumns,
};
renderMenu({
dataset: datasetWithFilteredColumns,
excludedColumns: excludedColNames.map(colName => ({
column_name: colName,
})),
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for menu items to be loaded
@@ -274,19 +285,12 @@ test('Do not display excluded column in the menu', async () => {
});
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
fetchMock
.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
})
.post(FORM_DATA_KEY_ENDPOINT, {})
.post(CHART_DATA_ENDPOINT, {});
const onSelectionMock = jest.fn();
renderMenu({
dataset: mockDataset,
onSelection: onSelectionMock,
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking

View File

@@ -32,25 +32,16 @@ import {
Behavior,
Column,
ContextMenuFilters,
JsonResponse,
css,
ensureIsArray,
getChartMetadataRegistry,
getExtensionsRegistry,
logging,
t,
useTheme,
} from '@superset-ui/core';
import { Constants, Input, Loading } from '@superset-ui/core/components';
import rison from 'rison';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { Icons } from '@superset-ui/core/components/Icons';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
@@ -73,27 +64,10 @@ export interface DrillByMenuItemsProps {
excludedColumns?: Column[];
open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
const queryString = rison.encode({
columns: [
'table_name',
'owners.first_name',
'owners.last_name',
'created_by.first_name',
'created_by.last_name',
'created_on_humanized',
'changed_by.first_name',
'changed_by.last_name',
'changed_on_humanized',
'columns.column_name',
'columns.verbose_name',
'columns.groupby',
],
});
export const DrillByMenuItems = ({
drillByConfig,
formData,
@@ -106,18 +80,16 @@ export const DrillByMenuItems = ({
openNewModal = true,
open,
onDrillBy,
dataset,
isLoadingDataset = false,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
const { addDangerToast } = useToasts();
const [isLoadingColumns, setIsLoadingColumns] = useState(true);
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [dataset, setDataset] = useState<Dataset>();
const [columns, setColumns] = useState<Column[]>([]);
const ref = useRef<InputRef>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const columns = dataset ? ensureIsArray(dataset.drillable_columns) : [];
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
@@ -151,56 +123,6 @@ export const DrillByMenuItems = ({
[formData.viz_type],
);
useEffect(() => {
async function loadOptions() {
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingColumns(true);
let response: JsonResponse;
if (loadDrillByOptions) {
response = await loadDrillByOptions(datasetId, formData);
} else {
response = await cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}?q=${queryString}`,
});
}
const { json } = response;
const { result } = json;
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
),
);
} catch (error) {
logging.error(error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
} finally {
setIsLoadingColumns(false);
}
}
if (handlesDimensionContextMenu && hasDrillBy) {
loadOptions();
}
}, [
addDangerToast,
drillByConfig?.groupbyFieldName,
excludedColumns,
formData,
handlesDimensionContextMenu,
hasDrillBy,
]);
const debouncedSetSearchInput = useMemo(
() =>
debounce((value: string) => {
@@ -316,7 +238,7 @@ export const DrillByMenuItems = ({
value={searchInput}
/>
)}
{isLoadingColumns ? (
{isLoadingDataset ? (
<div
css={css`
padding: ${theme.sizeUnit * 3}px 0;

View File

@@ -32,6 +32,11 @@ import mockState from 'spec/fixtures/mockState';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import DrillByModal, { DrillByModalProps } from './DrillByModal';
// Mock the isEmbedded function
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn(() => false),
}));
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
@@ -67,9 +72,14 @@ const dataset = {
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{ column_name: 'name' },
],
verbose_map: {},
};
const renderModal = async (
@@ -159,7 +169,7 @@ test('should render alert banner when results fail to load', async () => {
test('should generate Explore url', async () => {
await renderModal({
column: { column_name: 'name' },
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
@@ -222,7 +232,7 @@ test('should render radio buttons', async () => {
test('render breadcrumbs', async () => {
await renderModal({
column: { column_name: 'name' },
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
@@ -270,3 +280,79 @@ test('should render "Edit chart" enabled with can_explore permission', async ()
);
expect(screen.getByRole('button', { name: 'Edit chart' })).toBeEnabled();
});
describe('Embedded mode behavior', () => {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
const { isEmbedded } = require('src/dashboard/util/isEmbedded');
beforeEach(() => {
(isEmbedded as jest.Mock).mockClear();
});
afterEach(() => {
(isEmbedded as jest.Mock).mockReturnValue(false);
});
test('should not render "Edit chart" button in embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(true);
await renderModal();
expect(
screen.queryByRole('button', { name: 'Edit chart' }),
).not.toBeInTheDocument();
const footerCloseButton = screen.getByTestId('close-drill-by-modal');
expect(footerCloseButton).toHaveTextContent('Close');
});
test('should not call postFormData API in embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(true);
await renderModal({
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
expect(fetchMock.called(FORM_DATA_KEY_ENDPOINT)).toBe(false);
});
test('should render "Edit chart" button in non-embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(false);
await renderModal();
expect(
screen.getByRole('button', { name: 'Edit chart' }),
).toBeInTheDocument();
});
test('should call postFormData API in non-embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(false);
await renderModal({
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
await waitFor(() => {
expect(fetchMock.called(FORM_DATA_KEY_ENDPOINT)).toBe(true);
});
expect(
await screen.findByRole('link', { name: 'Edit chart' }),
).toHaveAttribute(
'href',
'/explore/?form_data_key=123&dashboard_page_id=1',
);
});
});

View File

@@ -56,6 +56,7 @@ import {
} from 'src/logger/LogUtils';
import { findPermission } from 'src/utils/findPermission';
import { getQuerySettings } from 'src/explore/exploreUtils';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Dataset, DrillByType } from '../types';
import DrillByChart from './DrillByChart';
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
@@ -93,6 +94,8 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
const [datasource_id, datasource_type] = formData.datasource.split('__');
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(
@@ -113,28 +116,30 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
return (
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onEditChartClick}
disabled={isEditDisabled}
tooltip={
isEditDisabled
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
<Link
css={css`
&:hover {
text-decoration: none;
}
`}
to={url}
{!isEmbedded() && (
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onEditChartClick}
disabled={isEditDisabled}
tooltip={
isEditDisabled
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Link>
</Button>
<Link
css={css`
&:hover {
text-decoration: none;
}
`}
to={url}
>
{t('Edit chart')}
</Link>
</Button>
)}
<Button
buttonStyle="primary"

View File

@@ -42,6 +42,7 @@ import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
@@ -113,6 +114,8 @@ export type DrillDetailMenuItemsProps = {
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
dataset?: Dataset;
isLoadingDataset?: boolean;
};
const DrillDetailMenuItems = ({

View File

@@ -29,9 +29,11 @@ import {
import { Button, Modal } from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
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 { Dataset } from '../types';
import DrillDetailPane from './DrillDetailPane';
interface ModalFooterProps {
@@ -49,19 +51,21 @@ const ModalFooter = ({
return (
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Button>
{!isEmbedded() && (
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Button>
)}
<Button
buttonStyle="primary"
buttonSize="small"
@@ -83,6 +87,7 @@ interface DrillDetailModalProps {
initialFilters: BinaryQueryObjectFilterClause[];
showModal: boolean;
onHideModal: () => void;
dataset?: Dataset;
}
export default function DrillDetailModal({
@@ -91,6 +96,7 @@ export default function DrillDetailModal({
initialFilters,
showModal,
onHideModal,
dataset,
}: DrillDetailModalProps) {
const theme = useTheme();
const history = useHistory();
@@ -141,7 +147,11 @@ export default function DrillDetailModal({
destroyOnHidden
maskClosable={false}
>
<DrillDetailPane formData={formData} initialFilters={initialFilters} />
<DrillDetailPane
formData={formData}
initialFilters={initialFilters}
dataset={dataset}
/>
</Modal>
);
}

View File

@@ -164,7 +164,7 @@ test('should render the "No results" components', async () => {
test('should render the metadata bar', async () => {
fetchWithNoData();
setup();
setup({ dataset: MOCKED_DATASET });
expect(
await screen.findByText(MOCKED_DATASET.table_name),
).toBeInTheDocument();
@@ -181,15 +181,6 @@ test('should render the metadata bar', async () => {
).toBeInTheDocument();
});
test('should render an error message when fails to load the metadata', async () => {
fetchWithNoData();
fetchMock.get(DATASET_ENDPOINT, { status: 400 }, { overwriteRoutes: true });
setup();
expect(
await screen.findByText('There was an error loading the dataset metadata'),
).toBeInTheDocument();
});
test('should render the error', async () => {
jest
.spyOn(SupersetClient, 'post')

View File

@@ -46,9 +46,10 @@ import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { RootState } from 'src/dashboard/types';
import HeaderWithRadioGroup from '@superset-ui/core/components/Table/header-renderers/HeaderWithRadioGroup';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
import { Dataset } from '../types';
import TableControls from './DrillDetailTableControls';
import { getDrillPayload } from './utils';
import { ResultsPage } from './types';
@@ -79,9 +80,11 @@ enum TimeFormatting {
export default function DrillDetailPane({
formData,
initialFilters,
dataset,
}: {
formData: QueryFormData;
initialFilters: BinaryQueryObjectFilterClause[];
dataset?: Dataset;
}) {
const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0);
@@ -96,6 +99,10 @@ export default function DrillDetailPane({
Record<string, TimeFormatting>
>({});
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const SAMPLES_ROW_LIMIT = useSelector(
(state: { common: { conf: JsonObject } }) =>
state.common.conf.SAMPLES_ROW_LIMIT,
@@ -107,9 +114,10 @@ export default function DrillDetailPane({
[formData.datasource],
);
const { metadataBar, status: metadataBarStatus } = useDatasetMetadataBar({
datasetId: datasourceId,
const { metadataBar: metadataBarComponent } = useDatasetMetadataBar({
dataset,
});
// Get page of results
const resultsPage = useMemo(() => {
const nextResultsPage = resultsPages.get(pageIndex);
@@ -231,6 +239,7 @@ export default function DrillDetailPane({
jsonPayload,
PAGE_SIZE,
pageIndex + 1,
dashboardId,
)
.then(response => {
setResultsPages(
@@ -268,9 +277,7 @@ export default function DrillDetailPane({
resultsPages,
]);
const bootstrapping =
(!responseError && !resultsPages.size) ||
metadataBarStatus === ResourceStatus.Loading;
const bootstrapping = !responseError && !resultsPages.size;
const allowHTML = formData.allow_render_html ?? true;
@@ -318,7 +325,7 @@ export default function DrillDetailPane({
return (
<>
{!bootstrapping && metadataBar}
{!bootstrapping && metadataBarComponent}
{!bootstrapping && (
<TableControls
filters={filters}

View File

@@ -601,6 +601,7 @@ export const getDatasourceSamples = async (
jsonPayload,
perPage,
page,
dashboardId,
) => {
try {
const searchParams = {
@@ -609,6 +610,10 @@ export const getDatasourceSamples = async (
datasource_id: datasourceId,
};
if (isDefined(dashboardId)) {
searchParams.dashboard_id = dashboardId;
}
if (isDefined(perPage) && isDefined(page)) {
searchParams.per_page = perPage;
searchParams.page = page;

View File

@@ -41,6 +41,7 @@ export type Dataset = {
last_name: string;
}[];
columns?: Column[];
drillable_columns?: Column[];
metrics?: Metric[];
verbose_map?: Record<string, string>;
};

View File

@@ -310,7 +310,7 @@ test('Drill to detail modal is under featureflag', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', () => {
test('Should show "Drill to detail" with `can_explore`, `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -320,13 +320,14 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', (
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', () => {
test('Should show "Drill to detail" with `can_drill` & `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -339,13 +340,14 @@ test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', ()
Admin: [
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_samples` perms', () => {
test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -357,7 +359,9 @@ test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_
renderWrapper(props, {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
@@ -380,7 +384,7 @@ test('Should not show "Drill to detail" with neither of required perms', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" only `can_dril` perm', () => {
test('Should not show "Drill to detail" only `can_drill` perm', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -396,6 +400,64 @@ test('Should not show "Drill to detail" only `can_dril` perm', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_drill` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_drill', 'Dashboard'],
['can_samples', 'Datasource'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_explore` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_explore`, `can_drill` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should show "View query"', () => {
const props = {
...createProps(),

View File

@@ -52,7 +52,23 @@ export default {
export const DatasetSpecific = () => {
SupersetClient.reset();
SupersetClient.configure({ csrfToken: '1234' }).init();
const { metadataBar } = useDatasetMetadataBar({ datasetId: 1 });
const mockDataset = {
changed_on: '2023-01-26T12:06:58.733316',
changed_on_humanized: 'a month ago',
changed_by: { first_name: 'Han', last_name: 'Solo' },
created_by: { first_name: 'Luke', last_name: 'Skywalker' },
created_on: '2023-01-26T12:06:54.965034',
created_on_humanized: 'a month ago',
table_name: `This is dataset's name`,
owners: [
{ first_name: 'John', last_name: 'Doe' },
{ first_name: 'Luke', last_name: 'Skywalker' },
],
description: 'This is a dataset description',
};
const { metadataBar } = useDatasetMetadataBar({ dataset: mockDataset });
const { width, height, ref } = useResizeDetector();
// eslint-disable-next-line no-param-reassign
return (

View File

@@ -43,35 +43,7 @@ afterEach(() => {
supersetGetCache.clear();
});
test('renders dataset metadata bar from request', async () => {
fetchMock.get('glob:*/api/v1/dataset/1', {
result: MOCK_DATASET,
});
const { result, waitForValueToChange } = renderHook(
() => useDatasetMetadataBar({ datasetId: 1 }),
{
wrapper: createWrapper(),
},
);
expect(result.current.status).toEqual('loading');
await waitForValueToChange(() => result.current.status);
expect(result.current.status).toEqual('complete');
expect(fetchMock.called()).toBeTruthy();
const { findByText, findAllByRole } = render(result.current.metadataBar);
expect(await findByText(`This is dataset's name`)).toBeVisible();
expect(await findByText('This is a dataset description')).toBeVisible();
expect(await findByText('Luke Skywalker')).toBeVisible();
expect(await findByText('a month ago')).toBeVisible();
expect(await findAllByRole('img')).toHaveLength(4);
});
test('renders dataset metadata bar without request', async () => {
fetchMock.get('glob:*/api/v1/dataset/1', {
result: {},
});
test('renders dataset metadata bar with dataset prop', async () => {
const { result } = renderHook(
() => useDatasetMetadataBar({ dataset: MOCK_DATASET }),
{
@@ -79,10 +51,8 @@ test('renders dataset metadata bar without request', async () => {
},
);
expect(result.current.status).toEqual('complete');
expect(fetchMock.called()).toBeFalsy();
const { findByText, findAllByRole } = render(result.current.metadataBar);
expect(result.current.metadataBar).not.toBeNull();
const { findByText, findAllByRole } = render(result.current.metadataBar!);
expect(await findByText(`This is dataset's name`)).toBeVisible();
expect(await findByText('This is a dataset description')).toBeVisible();
expect(await findByText('Luke Skywalker')).toBeVisible();
@@ -90,30 +60,27 @@ test('renders dataset metadata bar without request', async () => {
expect(await findAllByRole('img')).toHaveLength(4);
});
test('renders dataset metadata bar without description and owners', async () => {
fetchMock.get('glob:*/api/v1/dataset/1', {
result: {
changed_on: '2023-01-26T12:06:58.733316',
changed_on_humanized: 'a month ago',
created_on: '2023-01-26T12:06:54.965034',
created_on_humanized: 'a month ago',
table_name: `This is dataset's name`,
},
});
test('renders dataset metadata bar with minimal dataset', async () => {
const minimalDataset = {
changed_on: '2023-01-26T12:06:58.733316',
changed_on_humanized: 'a month ago',
created_on: '2023-01-26T12:06:54.965034',
created_on_humanized: 'a month ago',
table_name: `This is dataset's name`,
description: undefined,
owners: [],
} as any;
const { result, waitForValueToChange } = renderHook(
() => useDatasetMetadataBar({ datasetId: 1 }),
const { result } = renderHook(
() => useDatasetMetadataBar({ dataset: minimalDataset }),
{
wrapper: createWrapper(),
},
);
expect(result.current.status).toEqual('loading');
await waitForValueToChange(() => result.current.status);
expect(result.current.status).toEqual('complete');
expect(fetchMock.called()).toBeTruthy();
expect(result.current.metadataBar).not.toBeNull();
const { findByText, queryByText, findAllByRole } = render(
result.current.metadataBar,
result.current.metadataBar!,
);
expect(await findByText(`This is dataset's name`)).toBeVisible();
expect(queryByText('This is a dataset description')).not.toBeInTheDocument();

View File

@@ -16,49 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { css, t, useTheme } from '@superset-ui/core';
import { Alert } from '@superset-ui/core/components';
import { Dataset } from 'src/components/Chart/types';
import MetadataBar from '@superset-ui/core/components/MetadataBar';
import {
ContentType,
MetadataType,
} from '@superset-ui/core/components/MetadataBar/ContentType';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
export interface UseDatasetMetadataBarProps {
dataset?: Dataset;
}
export type UseDatasetMetadataBarProps =
| { datasetId?: undefined; dataset: Dataset }
| { datasetId: number | string; dataset?: undefined };
export const useDatasetMetadataBar = ({
dataset: datasetProps,
datasetId,
}: UseDatasetMetadataBarProps) => {
dataset,
}: UseDatasetMetadataBarProps): { metadataBar: React.ReactElement | null } => {
const theme = useTheme();
const [result, setResult] = useState<Dataset>();
const [status, setStatus] = useState<ResourceStatus>(
datasetProps ? ResourceStatus.Complete : ResourceStatus.Loading,
);
useEffect(() => {
if (!datasetProps && datasetId) {
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
})
.then(({ json: { result } }) => {
setResult(result);
setStatus(ResourceStatus.Complete);
})
.catch(() => {
setStatus(ResourceStatus.Error);
});
}
}, [datasetId, datasetProps]);
const metadataBar = useMemo(() => {
// Short-circuit for embedded users - they don't need metadata bar
if (isEmbedded()) {
return null;
}
const items: ContentType[] = [];
const dataset = datasetProps || result;
if (dataset) {
const {
changed_on_humanized,
@@ -110,21 +92,14 @@ export const useDatasetMetadataBar = ({
margin-bottom: ${theme.sizeUnit * 4}px;
`}
>
{status === ResourceStatus.Complete && (
{items.length > 0 && (
<MetadataBar items={items} tooltipPlacement="bottom" />
)}
{status === ResourceStatus.Error && (
<Alert
type="error"
message={t('There was an error loading the dataset metadata')}
/>
)}
</div>
);
}, [datasetProps, result, status, theme.sizeUnit]);
}, [dataset, theme.sizeUnit]);
return {
metadataBar,
status,
};
};

View File

@@ -36,8 +36,13 @@ export const usePermissions = () => {
const canDrill = useSelector((state: RootState) =>
findPermission('can_drill', 'Dashboard', state.user?.roles),
);
const canDrillBy = (canExplore || canDrill) && canWriteExploreFormData;
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples;
const canGetDrillInfo = useSelector((state: RootState) =>
findPermission('can_get_drill_info', 'Dataset', state.user?.roles),
);
const canDrillBy =
(canExplore || canDrill) && canWriteExploreFormData && canGetDrillInfo;
const canDrillToDetail =
(canExplore || canDrill) && canDatasourceSamples && canGetDrillInfo;
const canViewQuery = useSelector((state: RootState) =>
findPermission('can_view_query', 'Dashboard', state.user?.roles),
);