refactor: Redesigns the Results panel toolbar and enables extensions to contribute toolbar actions (#37255)

This commit is contained in:
Michael S. Molina
2026-01-21 08:49:32 -03:00
committed by GitHub
parent 25647942fd
commit d0e80d2079
14 changed files with 393 additions and 826 deletions

View File

@@ -34,18 +34,14 @@ const setup = (
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('ExploreResultsButton', () => {
test('renders', async () => {
const { queryByText } = setup(jest.fn(), {
setup(jest.fn(), {
database: { allows_subquery: true },
});
expect(queryByText('Create chart')).toBeInTheDocument();
// Updated line to match the actual button name that includes the icon
expect(screen.getByRole('button', { name: /Create chart/i })).toBeEnabled();
});
test('renders disabled if subquery not allowed', async () => {
const { queryByText } = setup(jest.fn());
expect(queryByText('Create chart')).toBeInTheDocument();
// Updated line to match the actual button name that includes the icon
setup(jest.fn());
expect(
screen.getByRole('button', { name: /Create chart/i }),
).toBeDisabled();

View File

@@ -38,16 +38,16 @@ const ExploreResultsButton = ({
return (
<Button
buttonSize="small"
buttonStyle="secondary"
icon={<Icons.LineChartOutlined />}
variant="text"
color="primary"
icon={<Icons.LineChartOutlined iconSize="m" />}
onClick={onClick}
disabled={!allowsSubquery}
role="button"
tooltip={t('Explore the result set in the data exploration view')}
tooltip={t('Create chart')}
aria-label={t('Create chart')}
data-test="explore-results-button"
>
{t('Create chart')}
</Button>
/>
);
};

View File

@@ -29,6 +29,8 @@ import { SqlLabRootState } from 'src/SqlLab/types';
import { useEditorQueriesQuery } from 'src/hooks/apiResources/queries';
import useEffectEvent from 'src/hooks/useEffectEvent';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
interface QueryHistoryProps {
queryEditorId: string | number;
@@ -119,6 +121,7 @@ const QueryHistory = ({
return editorQueries.length > 0 ? (
<>
<PanelToolbar viewId={ViewContribution.QueryHistory} />
<QueryTable
columns={[
'state',

View File

@@ -32,8 +32,8 @@ import { pick } from 'lodash';
import {
Button,
ButtonGroup,
Divider,
Tooltip,
Card,
Input,
Label,
Loading,
@@ -91,6 +91,8 @@ import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
import QueryStateLabel from '../QueryStateLabel';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
enum LimitingFactor {
Query = 'QUERY',
@@ -147,29 +149,11 @@ const ReturnedRows = styled.div`
line-height: 1;
`;
const ResultSetControls = styled.div`
display: flex;
justify-content: space-between;
`;
const ResultSetButtons = styled.div`
display: grid;
grid-auto-flow: column;
padding-right: ${({ theme }) => 2 * theme.sizeUnit}px;
`;
const CopyStyledButton = styled(Button)`
&:hover {
color: ${({ theme }) => theme.colorPrimary};
text-decoration: unset;
}
span > :first-of-type {
margin: 0;
}
`;
const ROWS_CHIP_WIDTH = 100;
const GAP = 8;
const extensionsRegistry = getExtensionsRegistry();
@@ -389,8 +373,71 @@ const ResultSet = ({
}
};
const defaultPrimaryActions = (
<>
{visualize && database?.allows_virtual_table_explore && (
<ExploreResultsButton
database={database}
onClick={createExploreResultsOnClick}
/>
)}
{csv && canExportData && (
<Button
buttonSize="small"
variant="text"
color="primary"
icon={<Icons.DownloadOutlined iconSize="m" />}
tooltip={t('Download to CSV')}
aria-label={t('Download to CSV')}
{...(!shouldUseStreamingExport() && {
href: getExportCsvUrl(query.id),
})}
data-test="export-csv-button"
onClick={e => {
const useStreaming = shouldUseStreamingExport();
if (useStreaming) {
e.preventDefault();
setShowStreamingModal(true);
startExport({
url: makeUrl('/api/v1/sqllab/export_streaming/'),
payload: { client_id: query.id },
exportType: 'csv',
expectedRows: rows,
});
} else {
handleDownloadCsv(e);
}
}}
/>
)}
{canExportData && (
<CopyToClipboard
text={prepareCopyToClipboardTabularData(data, columns)}
wrapped={false}
copyNode={
<Button
buttonSize="small"
variant="text"
color="primary"
icon={<Icons.CopyOutlined iconSize="m" />}
tooltip={t('Copy to Clipboard')}
aria-label={t('Copy to Clipboard')}
data-test="copy-to-clipboard-button"
/>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
)}
</>
);
return (
<ResultSetControls>
<ResultSetButtons>
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={() => setShowSaveDatasetModal(false)}
@@ -401,98 +448,21 @@ const ResultSet = ({
)}
datasource={datasource}
/>
<ResultSetButtons>
{visualize && database?.allows_virtual_table_explore && (
<ExploreResultsButton
database={database}
onClick={createExploreResultsOnClick}
/>
)}
{csv && canExportData && (
<CopyStyledButton
buttonSize="small"
buttonStyle="secondary"
{...(!shouldUseStreamingExport() && {
href: getExportCsvUrl(query.id),
})}
data-test="export-csv-button"
onClick={e => {
const useStreaming = shouldUseStreamingExport();
if (useStreaming) {
e.preventDefault();
setShowStreamingModal(true);
startExport({
url: makeUrl('/api/v1/sqllab/export_streaming/'),
payload: { client_id: query.id },
exportType: 'csv',
expectedRows: rows,
});
} else {
handleDownloadCsv(e);
}
}}
>
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
</CopyStyledButton>
)}
{canExportData && (
<CopyToClipboard
text={prepareCopyToClipboardTabularData(data, columns)}
wrapped={false}
copyNode={
<CopyStyledButton
buttonSize="small"
buttonStyle="secondary"
data-test="copy-to-clipboard-button"
>
<Icons.CopyOutlined iconSize="s" /> {t('Copy to Clipboard')}
</CopyStyledButton>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
)}
</ResultSetButtons>
{search && (
<Input
onChange={changeSearch}
value={searchText}
className="form-control input-sm"
placeholder={t('Filter results')}
/>
)}
</ResultSetControls>
<PanelToolbar
viewId={ViewContribution.Results}
defaultPrimaryActions={defaultPrimaryActions}
/>
</ResultSetButtons>
);
}
return <div />;
return null;
};
const renderRowsReturned = (alertMessage: boolean) => {
const renderRowsReturned = () => {
const { results, rows, queryLimit, limitingFactor } = query;
let limitMessage = '';
const limitReached = results?.displayLimitReached;
const limit = queryLimit || results.query.limit;
const isAdmin = !!user?.roles?.Admin;
const rowsCount = Math.min(rows || 0, results?.data?.length || 0);
const displayMaxRowsReachedMessage = {
withAdmin: t(
'The number of results displayed is limited to %(rows)d by the configuration DISPLAY_MAX_ROW. ' +
'Please add additional limits/filters or download to csv to see more rows up to ' +
'the %(limit)d limit.',
{ rows: rowsCount, limit },
),
withoutAdmin: t(
'The number of results displayed is limited to %(rows)d. ' +
'Please add additional limits/filters, download to csv, or contact an admin ' +
'to see more rows up to the %(limit)d limit.',
{ rows: rowsCount, limit },
),
};
const shouldUseDefaultDropdownAlert =
limit === defaultQueryLimit && limitingFactor === LimitingFactor.Dropdown;
@@ -514,76 +484,53 @@ const ResultSet = ({
'The number of rows displayed is limited to %(rows)d by the query and limit dropdown.',
{ rows },
);
} else if (shouldUseDefaultDropdownAlert) {
limitMessage = t(
'The number of rows displayed is limited to %(rows)d by the dropdown.',
{ rows },
);
} else if (limitReached) {
limitMessage = t(
'The number of results displayed is limited to %(rows)d.',
{ rows },
);
}
const formattedRowCount = getNumberFormatter()(rows);
const rowsReturnedMessage = t('%(rows)d rows returned', {
rows,
});
const tooltipText = `${rowsReturnedMessage}. ${limitMessage}`;
if (alertMessage) {
return (
<>
{!limitReached && shouldUseDefaultDropdownAlert && (
<div>
<Alert
closable
type="warning"
message={t(
'The number of rows displayed is limited to %(rows)d by the dropdown.',
{ rows },
)}
/>
</div>
)}
{limitReached && (
<div>
<Alert
closable
type="warning"
message={
isAdmin
? displayMaxRowsReachedMessage.withAdmin
: displayMaxRowsReachedMessage.withoutAdmin
}
/>
</div>
)}
</>
);
}
const showRowsReturned =
showSqlInline || (!limitReached && !shouldUseDefaultDropdownAlert);
const hasWarning = !!limitMessage;
const tooltipText = hasWarning
? `${rowsReturnedMessage}. ${limitMessage}`
: rowsReturnedMessage;
return (
<>
{showRowsReturned && (
<ReturnedRows>
<Tooltip
id="sqllab-rowcount-tooltip"
title={tooltipText}
placement="left"
>
<Label
<ReturnedRows>
<Tooltip
id="sqllab-rowcount-tooltip"
title={tooltipText}
placement="left"
>
<Label
css={css`
line-height: ${theme.fontSizeLG}px;
`}
>
{hasWarning && (
<Icons.WarningOutlined
css={css`
line-height: ${theme.fontSizeLG}px;
font-size: ${theme.fontSize}px;
margin-right: ${theme.sizeUnit}px;
color: ${theme.colorWarning};
`}
>
{limitMessage && (
<Icons.ExclamationCircleOutlined
css={css`
font-size: ${theme.fontSize}px;
margin-right: ${theme.sizeUnit}px;
`}
/>
)}
{tn('%s row', '%s rows', rows, formattedRowCount)}
</Label>
</Tooltip>
</ReturnedRows>
)}
</>
/>
)}
{tn('%s row', '%s rows', rows, formattedRowCount)}
</Label>
</Tooltip>
</ReturnedRows>
);
};
@@ -728,45 +675,58 @@ const ResultSet = ({
return (
<>
<ResultContainer>
{renderControls()}
{showSql && showSqlInline ? (
<>
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
gap: ${GAP}px;
`}
>
<Card
css={[
css`
height: 28px;
width: calc(100% - ${ROWS_CHIP_WIDTH + GAP}px);
code {
width: 100%;
overflow: hidden;
white-space: nowrap !important;
text-overflow: ellipsis;
display: block;
}
`,
]}
<div
css={css`
display: flex;
align-items: center;
gap: ${GAP}px;
& .ant-divider {
height: ${theme.sizeUnit * 6}px;
margin: 0 ${theme.sizeUnit * 2}px 0 0;
}
`}
>
{renderControls()}
<Divider type="vertical" />
{showSql && (
<>
<div
css={css`
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
margin-right: ${theme.sizeUnit}px;
& * {
overflow: hidden !important;
white-space: nowrap !important;
text-overflow: ellipsis !important;
}
pre {
margin: 0 !important;
}
`}
>
{sql}
</Card>
{renderRowsReturned(false)}
</div>
{renderRowsReturned(true)}
</>
) : (
<>
{renderRowsReturned(false)}
{renderRowsReturned(true)}
{sql}
</>
)}
</div>
<Divider type="vertical" />
</>
)}
{renderRowsReturned()}
{search && (
<Input
css={css`
flex: none;
width: 200px;
`}
onChange={changeSearch}
value={searchText}
placeholder={t('Filter results')}
/>
)}
</div>
{useFixedHeight && height !== undefined ? (
<ResultTable {...tableProps} height={height} />
) : (

View File

@@ -62,6 +62,7 @@ export type QueryPayload = {
} & Pick<QueryEditor, 'dbId' | 'catalog' | 'schema' | 'sql'>;
const Styles = styled.span`
display: contents;
span[role='img']:not([aria-label='down']) {
display: flex;
margin: 0;

View File

@@ -80,7 +80,7 @@ const Results: FC<Props> = ({
) {
return (
<Alert
type="warning"
type="info"
message={t('No stored results found, you need to re-run your query')}
/>
);

View File

@@ -29,7 +29,7 @@ import { Flex, Label } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { SqlLabRootState } from 'src/SqlLab/types';
import { ViewContribution } from 'src/SqlLab/contributions';
import MenuListExtension from 'src/components/MenuListExtension';
import PanelToolbar from 'src/components/PanelToolbar';
import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
@@ -215,7 +215,18 @@ const SouthPane = ({
...contributions.map(contribution => ({
key: contribution.id,
label: contribution.name,
children: getView(contribution.id),
children: (
<div
css={css`
& > div:first-of-type {
padding-bottom: ${theme.sizeUnit * 2}px;
}
`}
>
<PanelToolbar viewId={contribution.id} />
{getView(contribution.id)}
</div>
),
forceRender: true,
closable: false,
})),
@@ -231,8 +242,7 @@ const SouthPane = ({
padding: 8px;
`}
>
<MenuListExtension viewId={ViewContribution.Panels} primary />
<MenuListExtension viewId={ViewContribution.Panels} secondary />
<PanelToolbar viewId={ViewContribution.Panels} />
</Flex>
),
}}

View File

@@ -760,23 +760,19 @@ const SqlEditor: FC<Props> = ({
stopQuery={stopQuery}
overlayCreateAsMenu={showMenu ? runMenuBtn : null}
/>
<span>
<QueryLimitSelect
queryEditorId={queryEditor.id}
maxRow={maxRow}
defaultQueryLimit={defaultQueryLimit}
/>
</span>
<QueryLimitSelect
queryEditorId={queryEditor.id}
maxRow={maxRow}
defaultQueryLimit={defaultQueryLimit}
/>
<Divider type="vertical" />
{isFeatureEnabled(FeatureFlag.EstimateQueryCost) &&
database?.allows_cost_estimate && (
<span>
<EstimateQueryCostButton
getEstimate={getQueryCostEstimate}
queryEditorId={queryEditor.id}
tooltip={t('Estimate the cost before running a query')}
/>
</span>
<EstimateQueryCostButton
getEstimate={getQueryCostEstimate}
queryEditorId={queryEditor.id}
tooltip={t('Estimate the cost before running a query')}
/>
)}
<SaveQuery
queryEditorId={queryEditor.id}

View File

@@ -22,29 +22,23 @@ import SqlEditorTopBar, {
SqlEditorTopBarProps,
} from 'src/SqlLab/components/SqlEditorTopBar';
jest.mock('src/components/MenuListExtension', () => ({
jest.mock('src/components/PanelToolbar', () => ({
__esModule: true,
default: ({
children,
viewId,
primary,
secondary,
defaultItems,
defaultPrimaryActions,
defaultSecondaryActions,
}: {
children?: React.ReactNode;
viewId: string;
primary?: boolean;
secondary?: boolean;
defaultItems?: MenuItemType[];
defaultPrimaryActions?: React.ReactNode;
defaultSecondaryActions?: MenuItemType[];
}) => (
<div
data-test="mock-menu-extension"
data-test="mock-panel-toolbar"
data-view-id={viewId}
data-primary={primary}
data-secondary={secondary}
data-default-items-count={defaultItems?.length ?? 0}
data-default-secondary-count={defaultSecondaryActions?.length ?? 0}
>
{children}
{defaultPrimaryActions}
</div>
),
}));
@@ -63,30 +57,23 @@ const setup = (props?: Partial<SqlEditorTopBarProps>) =>
test('renders SqlEditorTopBar component', () => {
setup();
const menuExtensions = screen.getAllByTestId('mock-menu-extension');
expect(menuExtensions).toHaveLength(2);
const panelToolbar = screen.getByTestId('mock-panel-toolbar');
expect(panelToolbar).toBeInTheDocument();
});
test('renders primary MenuListExtension with correct props', () => {
test('renders PanelToolbar with correct viewId', () => {
setup();
const menuExtensions = screen.getAllByTestId('mock-menu-extension');
const primaryExtension = menuExtensions[0];
expect(primaryExtension).toHaveAttribute('data-view-id', 'sqllab.editor');
expect(primaryExtension).toHaveAttribute('data-primary', 'true');
const panelToolbar = screen.getByTestId('mock-panel-toolbar');
expect(panelToolbar).toHaveAttribute('data-view-id', 'sqllab.editor');
});
test('renders secondary MenuListExtension with correct props', () => {
test('renders PanelToolbar with correct secondary actions count', () => {
setup();
const menuExtensions = screen.getAllByTestId('mock-menu-extension');
const secondaryExtension = menuExtensions[1];
expect(secondaryExtension).toHaveAttribute('data-view-id', 'sqllab.editor');
expect(secondaryExtension).toHaveAttribute('data-secondary', 'true');
expect(secondaryExtension).toHaveAttribute('data-default-items-count', '2');
const panelToolbar = screen.getByTestId('mock-panel-toolbar');
expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '2');
});
test('renders defaultPrimaryActions as children of primary MenuListExtension', () => {
test('renders defaultPrimaryActions', () => {
setup();
expect(
screen.getByRole('button', { name: 'Primary Action' }),
@@ -114,17 +101,6 @@ test('renders with custom primary actions', () => {
test('renders with empty secondary actions', () => {
setup({ defaultSecondaryActions: [] });
const menuExtensions = screen.getAllByTestId('mock-menu-extension');
const secondaryExtension = menuExtensions[1];
expect(secondaryExtension).toHaveAttribute('data-default-items-count', '0');
});
test('passes correct viewId (ViewContribution.Editor) to MenuListExtension', () => {
setup();
const menuExtensions = screen.getAllByTestId('mock-menu-extension');
menuExtensions.forEach(extension => {
expect(extension).toHaveAttribute('data-view-id', 'sqllab.editor');
});
const panelToolbar = screen.getByTestId('mock-panel-toolbar');
expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '0');
});

View File

@@ -16,25 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Divider, Flex } from '@superset-ui/core/components';
import { Flex } from '@superset-ui/core/components';
import { styled } from '@apache-superset/core/ui';
import { MenuItemType } from '@superset-ui/core/components/Menu';
import { ViewContribution } from 'src/SqlLab/contributions';
import MenuListExtension, {
type MenuListExtensionProps,
} from 'src/components/MenuListExtension';
import PanelToolbar from 'src/components/PanelToolbar';
const StyledFlex = styled(Flex)`
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
& .ant-divider {
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
height: ${({ theme }) => theme.sizeUnit * 6}px;
}
padding: ${({ theme }) => theme.sizeUnit}px 0;
`;
export interface SqlEditorTopBarProps {
queryEditorId: string;
defaultPrimaryActions: React.ReactNode;
defaultSecondaryActions: MenuListExtensionProps['defaultItems'];
defaultSecondaryActions: MenuItemType[];
}
const SqlEditorTopBar = ({
@@ -43,18 +39,11 @@ const SqlEditorTopBar = ({
}: SqlEditorTopBarProps) => (
<StyledFlex justify="space-between" gap="small" id="js-sql-toolbar">
<Flex flex={1} gap="small" align="center">
<Flex gap="small" align="center">
<MenuListExtension viewId={ViewContribution.Editor} primary compactMode>
{defaultPrimaryActions}
</MenuListExtension>
</Flex>
<Divider type="vertical" />
<MenuListExtension
<PanelToolbar
viewId={ViewContribution.Editor}
secondary
defaultItems={defaultSecondaryActions}
defaultPrimaryActions={defaultPrimaryActions}
defaultSecondaryActions={defaultSecondaryActions}
/>
<Divider type="vertical" />
</Flex>
</StyledFlex>
);

View File

@@ -21,4 +21,6 @@ export enum ViewContribution {
Panels = 'sqllab.panels',
Editor = 'sqllab.editor',
StatusBar = 'sqllab.statusBar',
Results = 'sqllab.results',
QueryHistory = 'sqllab.queryHistory',
}