mirror of
https://github.com/apache/superset.git
synced 2026-05-06 16:34:32 +00:00
feat: Enable drilling in embedded (#34319)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,6 +41,7 @@ export type Dataset = {
|
||||
last_name: string;
|
||||
}[];
|
||||
columns?: Column[];
|
||||
drillable_columns?: Column[];
|
||||
metrics?: Metric[];
|
||||
verbose_map?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user