This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new d0e80d2079 refactor: Redesigns the Results panel toolbar and enables
extensions to contribute toolbar actions (#37255)
d0e80d2079 is described below
commit d0e80d2079bf7d5eb646c551141b1498b6838f5f
Author: Michael S. Molina <[email protected]>
AuthorDate: Wed Jan 21 08:49:32 2026 -0300
refactor: Redesigns the Results panel toolbar and enables extensions to
contribute toolbar actions (#37255)
---
.../ExploreResultsButton.test.tsx | 8 +-
.../components/ExploreResultsButton/index.tsx | 12 +-
.../src/SqlLab/components/QueryHistory/index.tsx | 3 +
.../src/SqlLab/components/ResultSet/index.tsx | 362 +++++++++-----------
.../src/SqlLab/components/SaveQuery/index.tsx | 1 +
.../src/SqlLab/components/SouthPane/Results.tsx | 2 +-
.../src/SqlLab/components/SouthPane/index.tsx | 18 +-
.../src/SqlLab/components/SqlEditor/index.tsx | 24 +-
.../SqlEditorTopBar/SqlEditorTopBar.test.tsx | 62 ++--
.../SqlLab/components/SqlEditorTopBar/index.tsx | 29 +-
superset-frontend/src/SqlLab/contributions.ts | 2 +
.../MenuListExtension/MenuListExtension.test.tsx | 374 ---------------------
.../src/components/MenuListExtension/index.tsx | 157 ---------
.../src/components/PanelToolbar/index.tsx | 165 +++++++++
14 files changed, 393 insertions(+), 826 deletions(-)
diff --git
a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx
b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx
index 63283a0862..9a00dda136 100644
---
a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx
+++
b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx
@@ -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();
diff --git
a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
index c485302510..39deb485b2 100644
--- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
@@ -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>
+ />
);
};
diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx
b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx
index d3e37a3586..b8c85f0628 100644
--- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx
@@ -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',
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index 439bf37ca2..5229063580 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -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} />
) : (
diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
index 96e025ac48..6d46c4cd78 100644
--- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
@@ -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;
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx
b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx
index 661efb7171..2f34149815 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx
@@ -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')}
/>
);
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index 1f4697db6b..4478e716bd 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -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>
),
}}
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index 5fe4f5367d..da2caaf006 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -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}
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
index d5a6871d52..224627b070 100644
---
a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
+++
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
@@ -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');
});
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
index f8b7ead860..0a3f00da3f 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
@@ -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>
);
diff --git a/superset-frontend/src/SqlLab/contributions.ts
b/superset-frontend/src/SqlLab/contributions.ts
index b9549bed28..bdaf184e32 100644
--- a/superset-frontend/src/SqlLab/contributions.ts
+++ b/superset-frontend/src/SqlLab/contributions.ts
@@ -21,4 +21,6 @@ export enum ViewContribution {
Panels = 'sqllab.panels',
Editor = 'sqllab.editor',
StatusBar = 'sqllab.statusBar',
+ Results = 'sqllab.results',
+ QueryHistory = 'sqllab.queryHistory',
}
diff --git
a/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
b/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
deleted file mode 100644
index 853c58c8aa..0000000000
---
a/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
+++ /dev/null
@@ -1,374 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { render, screen, waitFor } from 'spec/helpers/testing-library';
-import userEvent from '@testing-library/user-event';
-import type { contributions, core } from '@apache-superset/core';
-import ExtensionsManager from 'src/extensions/ExtensionsManager';
-import { commands } from 'src/core';
-import MenuListExtension from '.';
-
-jest.mock('src/core', () => ({
- commands: {
- executeCommand: jest.fn(),
- },
-}));
-
-function createMockCommand(
- command: string,
- overrides: Partial<contributions.CommandContribution> = {},
-): contributions.CommandContribution {
- return {
- command,
- icon: 'PlusOutlined',
- title: `${command} Title`,
- description: `${command} description`,
- ...overrides,
- };
-}
-
-function createMockMenuItem(
- view: string,
- command: string,
-): contributions.MenuItem {
- return {
- view,
- command,
- };
-}
-
-function createMockMenu(
- overrides: Partial<contributions.MenuContribution> = {},
-): contributions.MenuContribution {
- return {
- context: [],
- primary: [],
- secondary: [],
- ...overrides,
- };
-}
-
-function createMockExtension(
- options: Partial<core.Extension> & {
- commands?: contributions.CommandContribution[];
- menus?: Record<string, contributions.MenuContribution>;
- } = {},
-): core.Extension {
- const {
- id = 'test-extension',
- name = 'Test Extension',
- commands: cmds = [],
- menus = {},
- } = options;
-
- return {
- id,
- name,
- description: 'A test extension',
- version: '1.0.0',
- dependencies: [],
- remoteEntry: '',
- exposedModules: [],
- extensionDependencies: [],
- contributions: {
- commands: cmds,
- menus,
- views: {},
- },
- activate: jest.fn(),
- deactivate: jest.fn(),
- };
-}
-
-function setupActivatedExtension(
- manager: ExtensionsManager,
- extension: core.Extension,
-) {
- const context = { disposables: [] };
- (manager as any).contextIndex.set(extension.id, context);
- (manager as any).extensionContributions.set(extension.id, {
- commands: extension.contributions.commands,
- menus: extension.contributions.menus,
- views: extension.contributions.views,
- });
-}
-
-async function createActivatedExtension(
- manager: ExtensionsManager,
- extensionOptions: Parameters<typeof createMockExtension>[0] = {},
-): Promise<core.Extension> {
- const mockExtension = createMockExtension(extensionOptions);
- await manager.initializeExtension(mockExtension);
- setupActivatedExtension(manager, mockExtension);
- return mockExtension;
-}
-
-const TEST_VIEW_ID = 'test.menu';
-
-beforeEach(() => {
- (ExtensionsManager as any).instance = undefined;
- jest.clearAllMocks();
-});
-
-afterEach(() => {
- (ExtensionsManager as any).instance = undefined;
-});
-
-test('renders children when primary mode with no extensions', () => {
- render(
- <MenuListExtension viewId={TEST_VIEW_ID} primary>
- <button type="button">Child Button</button>
- </MenuListExtension>,
- );
-
- expect(
- screen.getByRole('button', { name: 'Child Button' }),
- ).toBeInTheDocument();
-});
-
-test('renders primary actions from extension contributions', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.action')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- primary: [createMockMenuItem('test-view', 'test.action')],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} primary />);
-
- expect(screen.getByText('test.action Title')).toBeInTheDocument();
-});
-
-test('renders primary actions with children', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.action')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- primary: [createMockMenuItem('test-view', 'test.action')],
- }),
- },
- });
-
- render(
- <MenuListExtension viewId={TEST_VIEW_ID} primary>
- <button type="button">Child Button</button>
- </MenuListExtension>,
- );
-
- expect(screen.getByText('test.action Title')).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: 'Child Button' }),
- ).toBeInTheDocument();
-});
-
-test('hides title in compact mode for primary actions', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.action')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- primary: [createMockMenuItem('test-view', 'test.action')],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} primary compactMode />);
-
- expect(screen.queryByText('test.action Title')).not.toBeInTheDocument();
- expect(screen.getByRole('button')).toBeInTheDocument();
-});
-
-test('executes command when primary action button is clicked', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.action')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- primary: [createMockMenuItem('test-view', 'test.action')],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} primary />);
-
- const button = screen.getByRole('button', { name: 'test.action Title' });
- await userEvent.click(button);
-
- expect(commands.executeCommand).toHaveBeenCalledWith('test.action');
-});
-
-test('returns null when secondary mode with no actions and no defaultItems',
() => {
- const { container } = render(
- <MenuListExtension viewId={TEST_VIEW_ID} secondary />,
- );
-
- expect(container).toBeEmptyDOMElement();
-});
-
-test('renders dropdown button when secondary mode with defaultItems', () => {
- render(
- <MenuListExtension
- viewId={TEST_VIEW_ID}
- secondary
- defaultItems={[{ key: 'item1', label: 'Item 1' }]}
- />,
- );
-
- expect(screen.getByRole('button')).toBeInTheDocument();
-});
-
-test('renders dropdown menu with defaultItems when clicked', async () => {
- render(
- <MenuListExtension
- viewId={TEST_VIEW_ID}
- secondary
- defaultItems={[
- { key: 'item1', label: 'Item 1' },
- { key: 'item2', label: 'Item 2' },
- ]}
- />,
- );
-
- const dropdownButton = screen.getByRole('button');
- await userEvent.click(dropdownButton);
-
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeInTheDocument();
- expect(screen.getByText('Item 2')).toBeInTheDocument();
- });
-});
-
-test('renders secondary actions from extension contributions', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.secondary')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- secondary: [createMockMenuItem('test-view', 'test.secondary')],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} secondary />);
-
- const dropdownButton = screen.getByRole('button');
- await userEvent.click(dropdownButton);
-
- await waitFor(() => {
- expect(screen.getByText('test.secondary Title')).toBeInTheDocument();
- });
-});
-
-test('merges extension secondary actions with defaultItems', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.secondary')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- secondary: [createMockMenuItem('test-view', 'test.secondary')],
- }),
- },
- });
-
- render(
- <MenuListExtension
- viewId={TEST_VIEW_ID}
- secondary
- defaultItems={[{ key: 'default-item', label: 'Default Item' }]}
- />,
- );
-
- const dropdownButton = screen.getByRole('button');
- await userEvent.click(dropdownButton);
-
- await waitFor(() => {
- expect(screen.getByText('test.secondary Title')).toBeInTheDocument();
- expect(screen.getByText('Default Item')).toBeInTheDocument();
- });
-});
-
-test('executes command when secondary menu item is clicked', async () => {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [createMockCommand('test.secondary')],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- secondary: [createMockMenuItem('test-view', 'test.secondary')],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} secondary />);
-
- const dropdownButton = screen.getByRole('button');
- await userEvent.click(dropdownButton);
-
- await waitFor(() => {
- expect(screen.getByText('test.secondary Title')).toBeInTheDocument();
- });
-
- const menuItem = screen.getByText('test.secondary Title');
- await userEvent.click(menuItem);
-
- expect(commands.executeCommand).toHaveBeenCalledWith('test.secondary');
-});
-
-test('renders multiple primary actions from multiple contributions', async ()
=> {
- const manager = ExtensionsManager.getInstance();
-
- await createActivatedExtension(manager, {
- commands: [
- createMockCommand('test.action1'),
- createMockCommand('test.action2'),
- ],
- menus: {
- [TEST_VIEW_ID]: createMockMenu({
- primary: [
- createMockMenuItem('test-view1', 'test.action1'),
- createMockMenuItem('test-view2', 'test.action2'),
- ],
- }),
- },
- });
-
- render(<MenuListExtension viewId={TEST_VIEW_ID} primary />);
-
- expect(await screen.findByText('test.action1 Title')).toBeInTheDocument();
- expect(screen.getByText('test.action2 Title')).toBeInTheDocument();
-});
-
-test('handles viewId with no matching contributions', () => {
- render(
- <MenuListExtension viewId="nonexistent.menu" primary>
- <button type="button">Fallback</button>
- </MenuListExtension>,
- );
-
- expect(screen.getByRole('button', { name: 'Fallback' })).toBeInTheDocument();
-});
diff --git a/superset-frontend/src/components/MenuListExtension/index.tsx
b/superset-frontend/src/components/MenuListExtension/index.tsx
deleted file mode 100644
index 98608f6a29..0000000000
--- a/superset-frontend/src/components/MenuListExtension/index.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useMemo } from 'react';
-import { css, useTheme } from '@apache-superset/core/ui';
-import { Button, Dropdown } from '@superset-ui/core/components';
-import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
-import { Icons } from '@superset-ui/core/components/Icons';
-import { commands } from 'src/core';
-import ExtensionsManager from 'src/extensions/ExtensionsManager';
-
-export type MenuListExtensionProps = {
- viewId: string;
-} & (
- | {
- primary: boolean;
- secondary?: never;
- children?: React.ReactNode;
- defaultItems?: never;
- compactMode?: boolean;
- }
- | {
- primary?: never;
- secondary: boolean;
- children?: never;
- defaultItems?: MenuItemType[];
- compactMode?: never;
- }
-);
-
-const MenuListExtension = ({
- viewId,
- primary,
- secondary,
- defaultItems,
- children,
- compactMode,
-}: MenuListExtensionProps) => {
- const theme = useTheme();
- const contributions =
- ExtensionsManager.getInstance().getMenuContributions(viewId);
-
- const actions = primary ? contributions?.primary : contributions?.secondary;
- const primaryActions = useMemo(
- () =>
- primary
- ? (actions || []).map(contribution => {
- const command =
- ExtensionsManager.getInstance().getCommandContribution(
- contribution.command,
- )!;
- if (!command?.icon) {
- return null;
- }
- const Icon =
- (Icons as Record<string, typeof Icons.FileOutlined>)[
- command.icon
- ] ?? Icons.FileOutlined;
-
- return (
- <Button
- key={contribution.view}
- onClick={() => commands.executeCommand(command?.command)}
- tooltip={command?.description ?? command?.title}
- icon={<Icon iconSize="m" />}
- buttonSize="small"
- aria-label={command?.title}
- {...(compactMode && { variant: 'text', color: 'primary' })}
- >
- {!compactMode ? command?.title : undefined}
- </Button>
- );
- })
- : [],
- [actions, primary, compactMode],
- );
- const secondaryActions = useMemo(
- () =>
- secondary
- ? (actions || [])
- .map(contribution => {
- const command =
- ExtensionsManager.getInstance().getCommandContribution(
- contribution.command,
- )!;
- if (!command) {
- return null;
- }
- return {
- key: command.command,
- label: command.title,
- title: command.description,
- onClick: () => commands.executeCommand(command.command),
- } as MenuItemType;
- })
- .concat(...(defaultItems || []))
- .filter(Boolean)
- : [],
- [actions, secondary, defaultItems],
- );
-
- if (secondary && secondaryActions.length === 0) {
- return null;
- }
-
- if (secondary) {
- return (
- <Dropdown
- popupRender={() => (
- <Menu
- css={css`
- & .ant-dropdown-menu-title-content > div {
- gap: ${theme.sizeUnit * 4}px;
- }
- `}
- items={secondaryActions}
- />
- )}
- trigger={['click']}
- >
- <Button
- showMarginRight={false}
- color="primary"
- variant="text"
- css={css`
- padding: 8px;
- `}
- >
- <Icons.MoreOutlined />
- </Button>
- </Dropdown>
- );
- }
- return (
- <>
- {primaryActions}
- {children}
- </>
- );
-};
-
-export default MenuListExtension;
diff --git a/superset-frontend/src/components/PanelToolbar/index.tsx
b/superset-frontend/src/components/PanelToolbar/index.tsx
new file mode 100644
index 0000000000..7f88197811
--- /dev/null
+++ b/superset-frontend/src/components/PanelToolbar/index.tsx
@@ -0,0 +1,165 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useMemo } from 'react';
+import { css, useTheme } from '@apache-superset/core/ui';
+import { Button, Divider, Dropdown } from '@superset-ui/core/components';
+import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { commands } from 'src/core';
+import ExtensionsManager from 'src/extensions/ExtensionsManager';
+
+export interface PanelToolbarProps {
+ viewId: string;
+ defaultPrimaryActions?: React.ReactNode;
+ defaultSecondaryActions?: MenuItemType[];
+}
+
+const PanelToolbar = ({
+ viewId,
+ defaultPrimaryActions,
+ defaultSecondaryActions,
+}: PanelToolbarProps) => {
+ const theme = useTheme();
+ const contributions =
+ ExtensionsManager.getInstance().getMenuContributions(viewId);
+
+ const primaryContributions = contributions?.primary || [];
+ const secondaryContributions = contributions?.secondary || [];
+
+ const extensionPrimaryActions = useMemo(
+ () =>
+ primaryContributions
+ .map(contribution => {
+ const command =
+ ExtensionsManager.getInstance().getCommandContribution(
+ contribution.command,
+ )!;
+ if (!command?.icon) {
+ return null;
+ }
+ const Icon =
+ (Icons as Record<string, typeof Icons.FileOutlined>)[
+ command.icon
+ ] ?? Icons.FileOutlined;
+
+ return (
+ <Button
+ key={contribution.view}
+ onClick={() => commands.executeCommand(command?.command)}
+ tooltip={command?.description ?? command?.title}
+ icon={<Icon iconSize="m" />}
+ buttonSize="small"
+ aria-label={command?.title}
+ variant="text"
+ color="primary"
+ />
+ );
+ })
+ .filter(Boolean),
+ [primaryContributions],
+ );
+
+ const secondaryActions = useMemo(
+ () =>
+ secondaryContributions
+ .map(contribution => {
+ const command =
+ ExtensionsManager.getInstance().getCommandContribution(
+ contribution.command,
+ )!;
+ if (!command) {
+ return null;
+ }
+ return {
+ key: command.command,
+ label: command.title,
+ title: command.description,
+ onClick: () => commands.executeCommand(command.command),
+ } as MenuItemType;
+ })
+ .filter(Boolean)
+ .concat(defaultSecondaryActions || []),
+ [secondaryContributions, defaultSecondaryActions],
+ );
+
+ const hasPrimaryActions =
+ !!defaultPrimaryActions || extensionPrimaryActions.length > 0;
+ const hasSecondaryActions = secondaryActions.length > 0;
+
+ // If no actions at all, render nothing
+ if (!hasPrimaryActions && !hasSecondaryActions) {
+ return null;
+ }
+
+ const toolbarStyles = css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+
+ & .ant-divider {
+ height: ${theme.sizeUnit * 6}px;
+ margin: 0;
+ }
+
+ & .superset-button {
+ margin-left: 0 !important;
+ min-width: ${theme.sizeUnit * 8}px;
+ }
+ `;
+
+ return (
+ <div css={toolbarStyles}>
+ {hasPrimaryActions && (
+ <>
+ {defaultPrimaryActions}
+ {extensionPrimaryActions}
+ </>
+ )}
+ {hasPrimaryActions && hasSecondaryActions && <Divider type="vertical" />}
+ {hasSecondaryActions && (
+ <Dropdown
+ popupRender={() => (
+ <Menu
+ css={css`
+ & .ant-dropdown-menu-title-content > div {
+ gap: ${theme.sizeUnit * 4}px;
+ }
+ `}
+ items={secondaryActions}
+ />
+ )}
+ trigger={['click']}
+ >
+ <Button
+ showMarginRight={false}
+ color="primary"
+ variant="text"
+ css={css`
+ padding: 8px;
+ `}
+ >
+ <Icons.MoreOutlined />
+ </Button>
+ </Dropdown>
+ )}
+ </div>
+ );
+};
+
+export default PanelToolbar;