This is an automated email from the ASF dual-hosted git repository.
justinpark 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 7503ee4e09 feat(sqllab): primary/secondary action extensions (#36644)
7503ee4e09 is described below
commit 7503ee4e09297a95812e26e73c36f6212ec57481
Author: JUST.in DO IT <[email protected]>
AuthorDate: Mon Jan 12 12:06:15 2026 -0800
feat(sqllab): primary/secondary action extensions (#36644)
---
.../src/components/AsyncAceEditor/index.tsx | 2 -
.../src/components/Icons/AntdEnhanced.tsx | 2 +
.../SqlLab/components/AceEditorWrapper/index.tsx | 26 +-
.../src/SqlLab/components/AppLayout/index.tsx | 22 +-
.../EstimateQueryCostButton.test.tsx | 28 +-
.../components/EstimateQueryCostButton/index.tsx | 9 +-
.../QueryLimitSelect/QueryLimitSelect.test.tsx | 3 +-
.../SqlLab/components/QueryLimitSelect/index.tsx | 28 +-
.../RunQueryActionButton.test.tsx | 1 -
.../components/RunQueryActionButton/index.tsx | 38 +--
.../SaveDatasetActionButton.test.tsx | 39 +--
.../components/SaveDatasetActionButton/index.tsx | 47 +--
.../SqlLab/components/SaveQuery/SaveQuery.test.tsx | 23 +-
.../src/SqlLab/components/SaveQuery/index.tsx | 22 +-
.../SqlLab/components/ShareSqlLabQuery/index.tsx | 10 +-
.../src/SqlLab/components/SouthPane/index.tsx | 23 +-
.../SqlLab/components/SqlEditor/SqlEditor.test.tsx | 6 +-
.../src/SqlLab/components/SqlEditor/index.tsx | 249 ++++++--------
.../SqlLab/components/SqlEditorLeftBar/index.tsx | 90 +----
.../SqlEditorTopBar/SqlEditorTopBar.test.tsx | 130 +++++++
.../SqlLab/components/SqlEditorTopBar/index.tsx | 62 ++++
.../SqlEditorTopBar/useDatabaseSelector.test.ts | 320 ++++++++++++++++++
.../SqlEditorTopBar/useDatabaseSelector.ts | 126 +++++++
.../StatusBar/StatusBar.test.tsx} | 29 +-
.../src/SqlLab/components/StatusBar/index.tsx | 57 ++++
.../SqlLab/components/TabbedSqlEditors/index.tsx | 40 ++-
superset-frontend/src/SqlLab/constants.ts | 3 +-
superset-frontend/src/SqlLab/contributions.ts | 4 +-
.../MenuListExtension/MenuListExtension.test.tsx | 374 +++++++++++++++++++++
.../src/components/MenuListExtension/index.tsx | 157 +++++++++
.../ViewListExtension/ViewListExtension.test.tsx | 198 +++++++++++
.../ViewListExtension/index.tsx} | 30 +-
32 files changed, 1803 insertions(+), 395 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
index 368cc02c7a..002d65cd07 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
@@ -273,10 +273,8 @@ export function AsyncAceEditor(
key="ace-tooltip-global"
styles={css`
.ace_editor {
- border: 1px solid ${token.colorBorder} !important;
background-color: ${token.colorBgContainer} !important;
}
-
/* Basic editor styles with dark mode support */
.ace_editor.ace-github,
.ace_editor.ace-tm {
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
index 6f9222470c..319227687b 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx
@@ -114,6 +114,7 @@ import {
ShareAltOutlined,
StarOutlined,
StarFilled,
+ StepForwardOutlined,
StopOutlined,
SunOutlined,
SyncOutlined,
@@ -258,6 +259,7 @@ const AntdIcons = {
SunOutlined,
StarOutlined,
StarFilled,
+ StepForwardOutlined,
StopOutlined,
SyncOutlined,
TagOutlined,
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
index bd01735f75..0a9231e26a 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
@@ -82,6 +82,7 @@ const AceEditorWrapper = ({
const currentSql = queryEditor.sql ?? '';
const [sql, setSql] = useState(currentSql);
+ const theme = useTheme();
// The editor changeSelection is called multiple times in a row,
// faster than React reconciliation process, so the selected text
@@ -126,7 +127,8 @@ const AceEditorWrapper = ({
exec: keyConfig.func,
});
});
-
+ const marginSize = theme.sizeUnit * 2;
+ editor.renderer.setScrollMargin(marginSize, marginSize, 0, 0);
editor.$blockScrolling = Infinity; // eslint-disable-line no-param-reassign
editor.selection.on('changeSelection', () => {
const selectedText = editor.getSelectedText();
@@ -178,7 +180,6 @@ const AceEditorWrapper = ({
},
!autocomplete,
);
- const theme = useTheme();
return (
<>
@@ -188,6 +189,27 @@ const AceEditorWrapper = ({
width: 100% !important;
}
+ .ace_content,
+ .SqlEditor .sql-container .ace_gutter {
+ background-color: ${theme.colorBgBase} !important;
+ }
+
+ .ace_gutter::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: ${theme.sizeUnit * 2}px;
+ width: 1px;
+ height: 100%;
+ background-color: ${theme.colorBorder};
+ }
+
+ .ace_gutter,
+ .ace_scroller {
+ background-color: ${theme.colorBgBase} !important;
+ }
+
.ace_autocomplete {
// Use !important because Ace Editor applies extra CSS at the last
second
// when opening the autocomplete.
diff --git a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx
b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx
index 8d8f06d34e..bb86e631eb 100644
--- a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx
@@ -19,11 +19,10 @@
import { useSelector } from 'react-redux';
import { noop } from 'lodash';
import type { SqlLabRootState } from 'src/SqlLab/types';
-import { styled } from '@apache-superset/core';
+import { css, styled } from '@apache-superset/core';
import { useComponentDidUpdate } from '@superset-ui/core';
import { Grid } from '@superset-ui/core/components';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
-import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import { Splitter } from 'src/components/Splitter';
import useEffectEvent from 'src/hooks/useEffectEvent';
import useStoredSidebarWidth from
'src/components/ResizableSidebar/useStoredSidebarWidth';
@@ -31,11 +30,15 @@ import {
SQL_EDITOR_LEFTBAR_WIDTH,
SQL_EDITOR_RIGHTBAR_WIDTH,
} from 'src/SqlLab/constants';
+import { ViewContribution } from 'src/SqlLab/contributions';
+import ViewListExtension from 'src/components/ViewListExtension';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
-import { ViewContribution } from 'src/SqlLab/contributions';
+import StatusBar from '../StatusBar';
const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
height: 100%;
& .ant-splitter-panel:not(.sqllab-body):not(.queryPane) {
@@ -93,11 +96,17 @@ const AppLayout: React.FC = ({ children }) => {
ExtensionsManager.getInstance().getViewContributions(
ViewContribution.RightSidebar,
) || [];
- const { getView } = useExtensionsContext();
return (
<StyledContainer>
- <Splitter lazy onResizeEnd={onSidebarChange} onResize={noop}>
+ <Splitter
+ css={css`
+ flex: 1;
+ `}
+ lazy
+ onResizeEnd={onSidebarChange}
+ onResize={noop}
+ >
<Splitter.Panel
collapsible={{
start: true,
@@ -126,11 +135,12 @@ const AppLayout: React.FC = ({ children }) => {
min={SQL_EDITOR_RIGHTBAR_WIDTH}
>
<ContentWrapper>
- {contributions.map(contribution => getView(contribution.id))}
+ <ViewListExtension viewId={ViewContribution.RightSidebar} />
</ContentWrapper>
</Splitter.Panel>
)}
</Splitter>
+ <StatusBar />
</StyledContainer>
);
};
diff --git
a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
index 684aef7b06..cda93761ff 100644
---
a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
+++
b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
@@ -56,22 +56,24 @@ const setup = (props:
Partial<EstimateQueryCostButtonProps>, store?: Store) =>
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
describe('EstimateQueryCostButton', () => {
test('renders EstimateQueryCostButton', async () => {
- const { queryByText } = setup({}, mockStore(initialState));
+ const { queryByLabelText } = setup({}, mockStore(initialState));
- expect(queryByText('Estimate cost')).toBeInTheDocument();
+ expect(queryByLabelText('Estimate cost')).toBeInTheDocument();
});
test('renders label for selected query', async () => {
- const { queryByText } = setup(
+ const { queryByLabelText } = setup(
{ queryEditorId: extraQueryEditor1.id },
mockStore(initialState),
);
- expect(queryByText('Estimate selected query cost')).toBeInTheDocument();
+ expect(
+ queryByLabelText('Estimate selected query cost'),
+ ).toBeInTheDocument();
});
test('renders label for selected query from unsaved', async () => {
- const { queryByText } = setup(
+ const { queryByLabelText } = setup(
{},
mockStore({
...initialState,
@@ -85,11 +87,13 @@ describe('EstimateQueryCostButton', () => {
}),
);
- expect(queryByText('Estimate selected query cost')).toBeInTheDocument();
+ expect(
+ queryByLabelText('Estimate selected query cost'),
+ ).toBeInTheDocument();
});
test('renders estimation error result', async () => {
- const { queryByText, getByText } = setup(
+ const { queryByLabelText, queryByText, getByLabelText } = setup(
{},
mockStore({
...initialState,
@@ -104,14 +108,14 @@ describe('EstimateQueryCostButton', () => {
}),
);
- expect(queryByText('Estimate cost')).toBeInTheDocument();
- fireEvent.click(getByText('Estimate cost'));
+ expect(queryByLabelText('Estimate cost')).toBeInTheDocument();
+ fireEvent.click(getByLabelText('Estimate cost'));
expect(queryByText('Estimate error')).toBeInTheDocument();
});
test('renders estimation success result', async () => {
- const { queryByText, getByText, findByTitle } = setup(
+ const { queryByLabelText, getByLabelText, findByTitle } = setup(
{},
mockStore({
...initialState,
@@ -127,8 +131,8 @@ describe('EstimateQueryCostButton', () => {
}),
);
- expect(queryByText('Estimate cost')).toBeInTheDocument();
- fireEvent.click(getByText('Estimate cost'));
+ expect(queryByLabelText('Estimate cost')).toBeInTheDocument();
+ fireEvent.click(getByLabelText('Estimate cost'));
const totalCostTitle = await findByTitle('Total cost');
expect(totalCostTitle).toBeInTheDocument();
});
diff --git
a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
index 227ff354ea..621020c410 100644
--- a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
@@ -27,6 +27,7 @@ import {
ModalTrigger,
TableView,
EmptyWrapperType,
+ Icons,
} from '@superset-ui/core/components';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { SqlLabRootState, QueryCostEstimate } from 'src/SqlLab/types';
@@ -111,14 +112,16 @@ const EstimateQueryCostButton = ({
modalBody={renderModalBody()}
triggerNode={
<Button
+ color="primary"
+ variant="text"
style={{ height: 32, padding: '4px 15px' }}
onClick={onClickHandler}
key="query-estimate-btn"
tooltip={tooltip}
disabled={disabled}
- >
- {btnText}
- </Button>
+ icon={<Icons.MonitorOutlined iconSize="m" />}
+ aria-label={btnText}
+ />
}
/>
</span>
diff --git
a/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx
b/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx
index 0544f49299..73b9025958 100644
---
a/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx
+++
b/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx
@@ -30,6 +30,7 @@ import { initialState, defaultQueryEditor } from
'src/SqlLab/fixtures';
import QueryLimitSelect, {
QueryLimitSelectProps,
convertToNumWithSpaces,
+ convertToShortNum,
} from 'src/SqlLab/components/QueryLimitSelect';
const middlewares = [thunk];
@@ -102,7 +103,7 @@ describe('QueryLimitSelect', () => {
},
}),
);
- expect(getByText(convertToNumWithSpaces(queryLimit))).toBeInTheDocument();
+ expect(getByText(convertToShortNum(queryLimit))).toBeInTheDocument();
});
test('renders dropdown select', async () => {
diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
index 59e9ba93bf..9b63b494df 100644
--- a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
@@ -34,6 +34,19 @@ export function convertToNumWithSpaces(num: number) {
return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ');
}
+export function convertToShortNum(num: number) {
+ if (num < 1000) {
+ return num;
+ }
+ if (num < 1_000_000) {
+ return `${num / 1000}K`;
+ }
+ if (num < 1_000_000_000) {
+ return `${num / 1000_000}M`;
+ }
+ return num;
+}
+
function renderQueryLimit(
maxRow: number,
setQueryLimit: (limit: number) => void,
@@ -74,12 +87,15 @@ const QueryLimitSelect = ({
popupRender={() => renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
- <Button size="small" showMarginRight={false} buttonStyle="link">
- <span>{t('LIMIT')}:</span>
- <span className="limitDropdown">
- {convertToNumWithSpaces(queryLimit)}
- </span>
- <Icons.CaretDownOutlined iconSize="m" />
+ <Button
+ size="small"
+ color="primary"
+ variant="text"
+ showMarginRight={false}
+ >
+ <span>{t('Limit')}</span>
+ <span className="limitDropdown">{convertToShortNum(queryLimit)}</span>
+ <Icons.DownOutlined iconSize="m" />
</Button>
</Dropdown>
);
diff --git
a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
index 2ac34b4bb7..61b5613792 100644
---
a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
+++
b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
@@ -38,7 +38,6 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect',
() => () => (
const defaultProps = {
queryEditorId: defaultQueryEditor.id,
- allowAsync: false,
dbId: 1,
queryState: 'ready',
runQuery: () => {},
diff --git
a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
index 5c2e8fa6f5..9bc54bb268 100644
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
@@ -33,10 +33,10 @@ import {
import useLogAction from 'src/logger/useLogAction';
export interface RunQueryActionButtonProps {
+ compactMode?: boolean;
queryEditorId: string;
- allowAsync: boolean;
queryState?: string;
- runQuery: (c?: boolean) => void;
+ runQuery: () => void;
stopQuery: () => void;
overlayCreateAsMenu: ReactElement | null;
}
@@ -47,13 +47,14 @@ const buildTextAndIcon = (
theme: SupersetTheme,
): { text: string; icon?: IconType } => {
let text = t('Run');
- let icon: IconType | undefined;
+ let icon: IconType | undefined = <Icons.CaretRightOutlined />;
if (selectedText) {
text = t('Run selection');
+ icon = <Icons.StepForwardOutlined />;
}
if (shouldShowStopButton) {
text = t('Stop');
- icon = <Icons.Square iconSize="xs" iconColor={theme.colorIcon} />;
+ icon = <Icons.Square iconColor={theme.colorIcon} />;
}
return {
text,
@@ -62,32 +63,27 @@ const buildTextAndIcon = (
};
const onClick = (
- shouldShowStopButton: boolean,
- allowAsync: boolean,
- runQuery: (c?: boolean) => void = () => undefined,
+ isStopAction: boolean,
+ runQuery: () => void = () => undefined,
stopQuery = () => {},
logAction: (name: string, payload: Record<string, any>) => void,
): void => {
- const eventName = shouldShowStopButton
+ const eventName = isStopAction
? LOG_ACTIONS_SQLLAB_STOP_QUERY
: LOG_ACTIONS_SQLLAB_RUN_QUERY;
logAction(eventName, { shortcut: false });
- if (shouldShowStopButton) return stopQuery();
- if (allowAsync) {
- return runQuery(true);
- }
- return runQuery(false);
+ if (isStopAction) return stopQuery();
+ runQuery();
};
const StyledButton = styled.span`
button {
line-height: 13px;
- // this is to over ride a previous transition built into the component
- transition: background-color 0ms;
- &:last-of-type {
- margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
- }
+ min-width: auto !important;
+ padding: 0 ${({ theme }) => theme.sizeUnit * 3}px 0
+ ${({ theme }) => theme.sizeUnit * 2}px;
+
span[name='caret-down'] {
display: flex;
margin-left: ${({ theme }) => theme.sizeUnit * 1}px;
@@ -96,7 +92,6 @@ const StyledButton = styled.span`
`;
const RunQueryActionButton = ({
- allowAsync = false,
queryEditorId,
queryState,
overlayCreateAsMenu,
@@ -142,7 +137,7 @@ const RunQueryActionButton = ({
<ButtonComponent
data-test="run-query-action"
onClick={() =>
- onClick(shouldShowStopBtn, allowAsync, runQuery, stopQuery,
logAction)
+ onClick(shouldShowStopBtn, runQuery, stopQuery, logAction)
}
disabled={isDisabled}
tooltip={
@@ -162,6 +157,8 @@ const RunQueryActionButton = ({
}
/>
),
+ type: 'primary',
+ danger: shouldShowStopBtn,
trigger: 'click',
}
: {
@@ -169,6 +166,7 @@ const RunQueryActionButton = ({
icon,
})}
>
+ {overlayCreateAsMenu && <>{icon}</>}
{text}
</ButtonComponent>
</StyledButton>
diff --git
a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx
b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx
index cd78007a6e..faaa5cb67f 100644
---
a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx
+++
b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx
@@ -16,50 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen, userEvent } from 'spec/helpers/testing-library';
-import { Menu } from '@superset-ui/core/components/Menu';
+import { render, screen } from 'spec/helpers/testing-library';
import SaveDatasetActionButton from
'src/SqlLab/components/SaveDatasetActionButton';
-const overlayMenu = (
- <Menu items={[{ label: 'Save dataset', key: 'save-dataset' }]} />
-);
-
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from
describe blocks
describe('SaveDatasetActionButton', () => {
test('renders a split save button', async () => {
+ const onSaveAsExplore = jest.fn();
render(
<SaveDatasetActionButton
setShowSave={() => true}
- overlayMenu={overlayMenu}
+ onSaveAsExplore={onSaveAsExplore}
/>,
);
- const saveBtn = screen.getByRole('button', { name: /save/i });
- const caretBtn = screen.getByRole('button', { name: /down/i });
+ const saveBtn = screen.getByRole('button', { name: 'Save' });
+ const saveDatasetBtn = screen.getByRole('button', {
+ name: /save dataset/i,
+ });
expect(
- await screen.findByRole('button', { name: /save/i }),
+ await screen.findByRole('button', { name: 'Save' }),
).toBeInTheDocument();
expect(saveBtn).toBeVisible();
- expect(caretBtn).toBeVisible();
- });
-
- test('renders a "save dataset" dropdown menu item when user clicks caret
button', async () => {
- render(
- <SaveDatasetActionButton
- setShowSave={() => true}
- overlayMenu={overlayMenu}
- />,
- );
-
- const caretBtn = screen.getByRole('button', { name: /down/i });
- expect(
- await screen.findByRole('button', { name: /down/i }),
- ).toBeInTheDocument();
- userEvent.click(caretBtn);
-
- const saveDatasetMenuItem = screen.getByText(/save dataset/i);
-
- expect(saveDatasetMenuItem).toBeInTheDocument();
+ expect(saveDatasetBtn).toBeVisible();
});
});
diff --git
a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx
b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx
index e1891b77fc..a68edb7efa 100644
--- a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx
@@ -17,37 +17,38 @@
* under the License.
*/
import { t } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/ui';
import { Icons } from '@superset-ui/core/components/Icons';
-import { Button, DropdownButton } from '@superset-ui/core/components';
+import { Button } from '@superset-ui/core/components';
interface SaveDatasetActionButtonProps {
setShowSave: (arg0: boolean) => void;
- overlayMenu: JSX.Element | null;
+ onSaveAsExplore?: () => void;
}
const SaveDatasetActionButton = ({
setShowSave,
- overlayMenu,
-}: SaveDatasetActionButtonProps) => {
- const theme = useTheme();
-
- return !overlayMenu ? (
- <Button onClick={() => setShowSave(true)} buttonStyle="primary">
- {t('Save')}
- </Button>
- ) : (
- <DropdownButton
+ onSaveAsExplore,
+}: SaveDatasetActionButtonProps) => (
+ <>
+ <Button
+ color="primary"
+ variant="text"
onClick={() => setShowSave(true)}
- popupRender={() => overlayMenu}
- icon={
- <Icons.DownOutlined iconSize="xs" iconColor={theme.colorPrimaryText} />
- }
- trigger={['click']}
- >
- {t('Save')}
- </DropdownButton>
- );
-};
+ icon={<Icons.SaveOutlined />}
+ tooltip={t('Save query')}
+ aria-label={t('Save')}
+ />
+ {onSaveAsExplore && (
+ <Button
+ color="primary"
+ variant="text"
+ onClick={() => onSaveAsExplore?.()}
+ icon={<Icons.TableOutlined />}
+ tooltip={t('Save or Overwrite Dataset')}
+ aria-label={t('Save dataset')}
+ />
+ )}
+ </>
+);
export default SaveDatasetActionButton;
diff --git
a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx
b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx
index 7d41400332..eb13d6acf5 100644
--- a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.tsx
@@ -179,11 +179,13 @@ describe('SavedQuery', () => {
});
await waitFor(() => {
- const saveBtn = screen.getByRole('button', { name: /save/i });
- const caretBtn = screen.getByRole('button', { name: /down/i });
+ const saveBtn = screen.getByRole('button', { name: 'Save' });
+ const saveDataSetBtn = screen.getByRole('button', {
+ name: /save dataset/i,
+ });
expect(saveBtn).toBeVisible();
- expect(caretBtn).toBeVisible();
+ expect(saveDataSetBtn).toBeVisible();
});
});
@@ -193,12 +195,7 @@ describe('SavedQuery', () => {
store: mockStore(mockState),
});
- const caretBtn = await screen.findByRole('button', {
- name: /down/i,
- });
- userEvent.click(caretBtn);
-
- const saveDatasetMenuItem = await screen.findByText(/save dataset/i);
+ const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
userEvent.click(saveDatasetMenuItem);
const saveDatasetHeader = screen.getByText(/save or overwrite dataset/i);
@@ -211,13 +208,7 @@ describe('SavedQuery', () => {
useRedux: true,
store: mockStore(mockState),
});
-
- const caretBtn = await screen.findByRole('button', {
- name: /down/i,
- });
- userEvent.click(caretBtn);
-
- const saveDatasetMenuItem = await screen.findByText(/save dataset/i);
+ const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
userEvent.click(saveDatasetMenuItem);
const closeBtn = screen.getByRole('button', { name: /close/i });
diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
index 6aad652ead..96e025ac48 100644
--- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
@@ -30,7 +30,6 @@ import {
Col,
Icons,
} from '@superset-ui/core/components';
-import { Menu } from '@superset-ui/core/components/Menu';
import SaveDatasetActionButton from
'src/SqlLab/components/SaveDatasetActionButton';
import {
SaveDatasetModal,
@@ -66,7 +65,6 @@ const Styles = styled.span`
span[role='img']:not([aria-label='down']) {
display: flex;
margin: 0;
- color: ${({ theme }) => theme.colorIcon};
svg {
vertical-align: -${({ theme }) => theme.sizeUnit * 1.25}px;
margin: 0;
@@ -116,20 +114,10 @@ const SaveQuery = ({
const shouldShowSaveButton =
database?.allows_virtual_table_explore !== undefined;
- const overlayMenu = (
- <Menu
- items={[
- {
- label: t('Save dataset'),
- key: 'save-dataset',
- onClick: () => {
- logAction(LOG_ACTIONS_SQLLAB_CREATE_CHART, {});
- setShowSaveDatasetModal(true);
- },
- },
- ]}
- />
- );
+ const onSaveAsExplore = () => {
+ logAction(LOG_ACTIONS_SQLLAB_CREATE_CHART, {});
+ setShowSaveDatasetModal(true);
+ };
const queryPayload = () => ({
name: label,
@@ -209,7 +197,7 @@ const SaveQuery = ({
{shouldShowSaveButton && (
<SaveDatasetActionButton
setShowSave={setShowSave}
- overlayMenu={canExploreDatabase ? overlayMenu : null}
+ onSaveAsExplore={canExploreDatabase ? onSaveAsExplore : undefined}
/>
)}
<SaveDatasetModal
diff --git a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx
b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx
index 28e93f662b..7b84b5325a 100644
--- a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx
@@ -71,18 +71,16 @@ const ShareSqlLabQuery = ({
const tooltip = t('Copy query link to your clipboard');
return (
<Button
- buttonSize="small"
- buttonStyle="secondary"
+ color="primary"
+ variant="text"
tooltip={tooltip}
css={css`
span > :first-of-type {
margin-right: 0;
}
`}
- >
- <Icons.LinkOutlined iconSize="m" />
- {t('Copy link')}
- </Button>
+ icon={<Icons.LinkOutlined iconSize="m" />}
+ />
);
};
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index 529cdaec77..1f4697db6b 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -25,9 +25,11 @@ import { css, styled, useTheme } from
'@apache-superset/core/ui';
import { removeTables, setActiveSouthPaneTab } from
'src/SqlLab/actions/sqlLab';
-import { Label } from '@superset-ui/core/components';
+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 { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
@@ -41,7 +43,6 @@ import {
} from '../../constants';
import Results from './Results';
import TablePreview from '../TablePreview';
-import { ViewContribution } from 'src/SqlLab/contributions';
/*
editorQueries are queries executed by users passed from SqlEditor component
@@ -73,6 +74,10 @@ const StyledPane = styled.div`
overflow-y: auto;
}
}
+ .ant-tabs-extra-content {
+ margin: 0 ${({ theme }) => theme.sizeUnit * 4}px
+ ${({ theme }) => theme.sizeUnit * 2}px;
+ }
.ant-tabs-tabpane {
.scrollable {
overflow-y: auto;
@@ -101,7 +106,7 @@ const SouthPane = ({
const dispatch = useDispatch();
const contributions =
ExtensionsManager.getInstance().getViewContributions(
- ViewContribution.SouthPanels,
+ ViewContribution.Panels,
) || [];
const { getView } = useExtensionsContext();
const { offline, tables } = useSelector(
@@ -219,6 +224,18 @@ const SouthPane = ({
return (
<StyledPane data-test="south-pane" className="SouthPane"
ref={southPaneRef}>
<Tabs
+ tabBarExtraContent={{
+ right: (
+ <Flex
+ css={css`
+ padding: 8px;
+ `}
+ >
+ <MenuListExtension viewId={ViewContribution.Panels} primary />
+ <MenuListExtension viewId={ViewContribution.Panels} secondary />
+ </Flex>
+ ),
+ }}
type="editable-card"
activeKey={pinnedTableKeys[activeSouthPaneTab] || activeSouthPaneTab}
className="SouthPaneTabs"
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
index b0b7cd03e2..b15bbdadc4 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx
@@ -321,7 +321,7 @@ describe('SqlEditor', () => {
const defaultQueryLimit = 101;
const updatedProps = { ...mockedProps, defaultQueryLimit };
const { findByText } = setup(updatedProps, store);
- fireEvent.click(await findByText('LIMIT:'));
+ fireEvent.click(await findByText('Limit'));
expect(await findByText('10 000')).toBeInTheDocument();
});
@@ -382,8 +382,8 @@ describe('SqlEditor', () => {
},
},
});
- const { findByText } = setup(mockedProps, store);
- const button = await findByText('Estimate cost');
+ const { findByLabelText } = setup(mockedProps, store);
+ const button = await findByLabelText('Estimate cost');
expect(button).toBeInTheDocument();
// click button
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index 792cc0a9ad..8426a3f981 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -51,17 +51,15 @@ import { debounce, isEmpty } from 'lodash';
import Mousetrap from 'mousetrap';
import {
Button,
- Dropdown,
+ Divider,
EmptyState,
Input,
Modal,
- Timer,
} from '@superset-ui/core/components';
import { Splitter } from 'src/components/Splitter';
import { Skeleton } from '@superset-ui/core/components/Skeleton';
import { Switch } from '@superset-ui/core/components/Switch';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
-import { Icons } from '@superset-ui/core/components/Icons';
import { detectOS } from 'src/utils/common';
import {
addNewQueryEditor,
@@ -85,7 +83,6 @@ import {
switchQueryEditor,
} from 'src/SqlLab/actions/sqlLab';
import {
- STATE_TYPE_MAP,
SQL_EDITOR_GUTTER_HEIGHT,
INITIAL_NORTH_PERCENT,
SET_QUERY_EDITOR_SQL_DEBOUNCE_MS,
@@ -107,8 +104,6 @@ import {
LOG_ACTIONS_SQLLAB_STOP_QUERY,
Logger,
} from 'src/logger/LogUtils';
-import ExtensionsManager from 'src/extensions/ExtensionsManager';
-import { commands } from 'src/core';
import { CopyToClipboard } from 'src/components';
import TemplateParamsEditor from '../TemplateParamsEditor';
import SouthPane from '../SouthPane';
@@ -123,6 +118,7 @@ import KeyboardShortcutButton, {
KEY_MAP,
KeyboardShortcut,
} from '../KeyboardShortcutButton';
+import SqlEditorTopBar from '../SqlEditorTopBar';
const bootstrapData = getBootstrapData();
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
@@ -166,34 +162,56 @@ const StyledSqlEditor = styled.div`
height: 100%;
.queryPane {
- padding: ${theme.sizeUnit * 2}px 0px;
+ padding: 0;
+ .ant-splitter-bar .ant-splitter-bar-dragger {
&::before {
- background: transparent;
+ height: 1px;
+ background-color: ${theme.colorBorder};
+ transform: translateX(-50%) !important;
}
&::after {
height: ${SQL_EDITOR_GUTTER_HEIGHT}px;
background: transparent;
border-top: 1px solid ${theme.colorBorder};
border-bottom: 1px solid ${theme.colorBorder};
+ transform: translate(-50%, -2px);
}
}
}
.north-pane {
+ padding: ${theme.sizeUnit * 2}px 0 0 0;
height: 100%;
margin: 0 ${theme.sizeUnit * 4}px;
}
- .SouthPane .ant-tabs-tabpane {
- margin: 0 ${theme.sizeUnit * 4}px;
- & .ant-tabs {
- margin: 0 ${theme.sizeUnit * -4}px;
+ .SouthPane {
+ & .ant-tabs-tabpane {
+ margin: 0 ${theme.sizeUnit * 4}px;
+ & .ant-tabs {
+ margin: 0 ${theme.sizeUnit * -4}px;
+ }
+ }
+ & .ant-tabs-tab {
+ box-shadow: none !important;
+ background: transparent !important;
+ border-color: transparent !important;
+ margin-top: ${theme.sizeUnit * 2}px !important;
+ &.ant-tabs-tab-active {
+ border-bottom-color: ${theme.colorPrimary} !important;
+ & .ant-tabs-tab-btn {
+ font-weight: ${theme.fontWeightStrong};
+ color: ${theme.colorTextBase} !important;
+ text-shadow: none !important;
+ }
+ }
}
}
.sql-container {
flex: 1 1 auto;
+ margin: 0 ${theme.sizeUnit * -4}px;
+ box-shadow: 0 0 0 1px ${theme.colorBorder};
}
`}
`;
@@ -615,30 +633,13 @@ const SqlEditor: FC<Props> = ({
setCtas(event.target.value);
};
- const renderDropdown = () => {
+ const getSecondaryMenuItems = () => {
const qe = queryEditor;
const successful = latestQuery?.state === 'success';
const scheduleToolTip = successful
? t('Schedule the query periodically')
: t('You must run the query successfully first');
- const contributions =
- ExtensionsManager.getInstance().getMenuContributions('sqllab.editor');
-
- const secondaryContributions = (contributions?.secondary || []).map(
- contribution => {
- const command = ExtensionsManager.getInstance().getCommandContribution(
- contribution.command,
- )!;
- return {
- key: command.command,
- label: command.title,
- title: command.description,
- onClick: () => commands.executeCommand(command.command),
- };
- },
- );
-
const menuItems: MenuItemType[] = [
{
key: 'render-html',
@@ -710,10 +711,9 @@ const SqlEditor: FC<Props> = ({
</KeyboardShortcutButton>
),
},
- ...secondaryContributions,
].filter(Boolean) as MenuItemType[];
- return <Menu css={{ width: theme.sizeUnit * 50 }} items={menuItems} />;
+ return menuItems;
};
const onSaveQuery = async (query: QueryPayload, clientId: string) => {
@@ -721,34 +721,8 @@ const SqlEditor: FC<Props> = ({
dispatch(addSavedQueryToTabState(queryEditor, savedQuery));
};
- const renderEditorBottomBar = (hideActions: boolean) => {
+ const renderEditorPrimaryAction = () => {
const { allow_ctas: allowCTAS, allow_cvas: allowCVAS } = database || {};
-
- const contributions =
- ExtensionsManager.getInstance().getMenuContributions('sqllab.editor');
-
- const primaryContributions = (contributions?.primary || []).map(
- contribution => {
- const command = ExtensionsManager.getInstance().getCommandContribution(
- contribution.command,
- )!;
- // @ts-ignore
- const Icon = Icons[command?.icon as IconNameType];
-
- return (
- <Button
- key={contribution.view}
- onClick={() => commands.executeCommand(command.command)}
- tooltip={command?.description}
- icon={<Icon iconSize="m" iconColor={theme.colorPrimary} />}
- buttonSize="small"
- >
- {command?.title}
- </Button>
- );
- },
- );
-
const showMenu = allowCTAS || allowCVAS;
const menuItems: MenuItemType[] = [
allowCTAS && {
@@ -778,93 +752,62 @@ const SqlEditor: FC<Props> = ({
const runMenuBtn = <Menu items={menuItems} />;
return (
- <StyledToolbar className="sql-toolbar" id="js-sql-toolbar">
- {hideActions ? (
- <Alert
- type="warning"
- message={t(
- 'The database that was used to generate this query could not be
found',
- )}
- description={t(
- 'Choose one of the available databases on the left panel.',
- )}
- closable={false}
+ <>
+ <RunQueryActionButton
+ queryEditorId={queryEditor.id}
+ queryState={latestQuery?.state}
+ runQuery={runQuery}
+ stopQuery={stopQuery}
+ overlayCreateAsMenu={showMenu ? runMenuBtn : null}
+ />
+ <span>
+ <QueryLimitSelect
+ queryEditorId={queryEditor.id}
+ maxRow={maxRow}
+ defaultQueryLimit={defaultQueryLimit}
/>
- ) : (
- <>
- <div className="leftItems">
- <span>
- <RunQueryActionButton
- allowAsync={database?.allow_run_async === true}
- queryEditorId={queryEditor.id}
- queryState={latestQuery?.state}
- runQuery={runQuery}
- stopQuery={stopQuery}
- overlayCreateAsMenu={showMenu ? runMenuBtn : null}
- />
- </span>
- {isFeatureEnabled(FeatureFlag.EstimateQueryCost) &&
- database?.allows_cost_estimate && (
- <span>
- <EstimateQueryCostButton
- getEstimate={getQueryCostEstimate}
- queryEditorId={queryEditor.id}
- tooltip={t('Estimate the cost before running a query')}
- />
- </span>
- )}
- <span>
- <QueryLimitSelect
- queryEditorId={queryEditor.id}
- maxRow={maxRow}
- defaultQueryLimit={defaultQueryLimit}
- />
- </span>
- {latestQuery && (
- <Timer
- startTime={latestQuery.startDttm}
- endTime={latestQuery.endDttm}
- status={STATE_TYPE_MAP[latestQuery.state]}
- isRunning={latestQuery.state === 'running'}
- />
- )}
- </div>
- <div className="rightItems">
- <span>
- <SaveQuery
- queryEditorId={queryEditor.id}
- columns={latestQuery?.results?.columns || []}
- onSave={onSaveQuery}
- onUpdate={(query, remoteId) =>
- dispatch(updateSavedQuery(query, remoteId))
- }
- saveQueryWarning={saveQueryWarning}
- database={database}
- />
- </span>
- <span>
- <ShareSqlLabQuery queryEditorId={queryEditor.id} />
- </span>
- <div>{primaryContributions}</div>
- <Dropdown
- popupRender={() => renderDropdown()}
- trigger={['click']}
- >
- <Button
- buttonSize="xsmall"
- showMarginRight={false}
- buttonStyle="link"
- >
- <Icons.EllipsisOutlined />
- </Button>
- </Dropdown>
- </div>
- </>
- )}
- </StyledToolbar>
+ </span>
+ <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>
+ )}
+ <SaveQuery
+ queryEditorId={queryEditor.id}
+ columns={latestQuery?.results?.columns || []}
+ onSave={onSaveQuery}
+ onUpdate={(query, remoteId) =>
+ dispatch(updateSavedQuery(query, remoteId))
+ }
+ saveQueryWarning={saveQueryWarning}
+ database={database}
+ />
+ <ShareSqlLabQuery queryEditorId={queryEditor.id} />
+ </>
);
};
+ const renderEmptyAlert = () => (
+ <StyledToolbar className="sql-toolbar" id="js-sql-toolbar">
+ <Alert
+ type="warning"
+ message={t(
+ 'The database that was used to generate this query could not be
found',
+ )}
+ description={t(
+ 'Choose one of the available databases on the left panel.',
+ )}
+ closable={false}
+ />
+ </StyledToolbar>
+ );
+
const handleCursorPositionChange = (newPosition: CursorPosition) => {
dispatch(queryEditorSetCursorPosition(queryEditor, newPosition));
};
@@ -950,13 +893,13 @@ const SqlEditor: FC<Props> = ({
className="queryPane"
>
<div className="north-pane">
- {SqlFormExtension && (
- <SqlFormExtension
+ {showEmptyState ? (
+ renderEmptyAlert()
+ ) : (
+ <SqlEditorTopBar
queryEditorId={queryEditor.id}
- setQueryEditorAndSaveSqlWithDebounce={
- setQueryEditorAndSaveSqlWithDebounce
- }
- startQuery={startQuery}
+ defaultPrimaryActions={renderEditorPrimaryAction()}
+ defaultSecondaryActions={getSecondaryMenuItems()}
/>
)}
{queryEditor.isDataset && renderDatasetWarning()}
@@ -977,7 +920,15 @@ const SqlEditor: FC<Props> = ({
}
</AutoSizer>
</div>
- {renderEditorBottomBar(showEmptyState)}
+ {SqlFormExtension && (
+ <SqlFormExtension
+ queryEditorId={queryEditor.id}
+ setQueryEditorAndSaveSqlWithDebounce={
+ setQueryEditorAndSaveSqlWithDebounce
+ }
+ startQuery={startQuery}
+ />
+ )}
</div>
</Splitter.Panel>
<Splitter.Panel className="queryPane">
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index 7d9a025864..5010298da0 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -16,36 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect, useCallback, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { SqlLabRootState, Table } from 'src/SqlLab/types';
import {
- queryEditorSetDb,
addTable,
removeTables,
collapseTable,
expandTable,
- queryEditorSetCatalog,
- queryEditorSetSchema,
- setDatabases,
- addDangerToast,
resetState,
- type Database,
} from 'src/SqlLab/actions/sqlLab';
import { Button, EmptyState, Icons } from '@superset-ui/core/components';
-import { type DatabaseObject } from 'src/components';
import { t } from '@apache-superset/core';
import { styled, css } from '@apache-superset/core/ui';
import { TableSelectorMultiple } from 'src/components/TableSelector';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
-import {
- getItem,
- LocalStorageKeys,
- setItem,
-} from 'src/utils/localStorageHelpers';
import { noop } from 'lodash';
import TableElement from '../TableElement';
+import useDatabaseSelector from '../SqlEditorTopBar/useDatabaseSelector';
export interface SqlEditorLeftBarProps {
queryEditorId: string;
@@ -70,10 +59,8 @@ const LeftBarStyles = styled.div`
`;
const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
- const databases = useSelector<
- SqlLabRootState,
- SqlLabRootState['sqlLab']['databases']
- >(({ sqlLab }) => sqlLab.databases);
+ const { db: userSelectedDb, ...dbSelectorProps } =
+ useDatabaseSelector(queryEditorId);
const allSelectedTables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) =>
sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
@@ -86,16 +73,8 @@ const SqlEditorLeftBar = ({ queryEditorId }:
SqlEditorLeftBarProps) => {
'schema',
'tabViewId',
]);
- const database = useMemo(
- () => (queryEditor.dbId ? databases[queryEditor.dbId] : undefined),
- [databases, queryEditor.dbId],
- );
-
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
- const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
- null,
- );
- const { dbId, catalog, schema } = queryEditor;
+ const { dbId, schema } = queryEditor;
const tables = useMemo(
() =>
allSelectedTables.filter(
@@ -106,29 +85,10 @@ const SqlEditorLeftBar = ({ queryEditorId }:
SqlEditorLeftBarProps) => {
noop(_emptyResultsWithSearch); // This is to avoid unused variable warning,
can be removed if not needed
- useEffect(() => {
- const bool = new URLSearchParams(window.location.search).get('db');
- const userSelected = getItem(
- LocalStorageKeys.Database,
- null,
- ) as DatabaseObject | null;
-
- if (bool && userSelected) {
- setUserSelected(userSelected);
- setItem(LocalStorageKeys.Database, null);
- } else if (database) {
- setUserSelected(database);
- }
- }, [database]);
-
const onEmptyResults = useCallback((searchText?: string) => {
setEmptyResultsWithSearch(!!searchText);
}, []);
- const onDbChange = ({ id: dbId }: { id: number }) => {
- dispatch(queryEditorSetDb(queryEditor, dbId));
- };
-
const selectedTableNames = useMemo(
() => tables?.map(table => table.name) || [],
[tables],
@@ -176,38 +136,6 @@ const SqlEditorLeftBar = ({ queryEditorId }:
SqlEditorLeftBarProps) => {
const shouldShowReset = window.location.search === '?reset=1';
- const handleCatalogChange = useCallback(
- (catalog: string | null) => {
- if (queryEditor) {
- dispatch(queryEditorSetCatalog(queryEditor, catalog));
- }
- },
- [dispatch, queryEditor],
- );
-
- const handleSchemaChange = useCallback(
- (schema: string) => {
- if (queryEditor) {
- dispatch(queryEditorSetSchema(queryEditor, schema));
- }
- },
- [dispatch, queryEditor],
- );
-
- const handleDbList = useCallback(
- (result: DatabaseObject[]) => {
- dispatch(setDatabases(result as unknown as Database[]));
- },
- [dispatch],
- );
-
- const handleError = useCallback(
- (message: string) => {
- dispatch(addDangerToast(message));
- },
- [dispatch],
- );
-
const handleResetState = useCallback(() => {
dispatch(resetState());
}, [dispatch]);
@@ -215,16 +143,10 @@ const SqlEditorLeftBar = ({ queryEditorId }:
SqlEditorLeftBarProps) => {
return (
<LeftBarStyles data-test="sql-editor-left-bar">
<TableSelectorMultiple
+ {...dbSelectorProps}
onEmptyResults={onEmptyResults}
emptyState={<EmptyState />}
database={userSelectedDb}
- getDbList={handleDbList}
- handleError={handleError}
- onDbChange={onDbChange}
- onCatalogChange={handleCatalogChange}
- catalog={catalog}
- onSchemaChange={handleSchemaChange}
- schema={schema}
onTableSelectChange={onTablesChange}
tableValue={selectedTableNames}
sqlLabMode
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
new file mode 100644
index 0000000000..d5a6871d52
--- /dev/null
+++
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
@@ -0,0 +1,130 @@
+/**
+ * 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 } from 'spec/helpers/testing-library';
+import { MenuItemType } from '@superset-ui/core/components/Menu';
+import SqlEditorTopBar, {
+ SqlEditorTopBarProps,
+} from 'src/SqlLab/components/SqlEditorTopBar';
+
+jest.mock('src/components/MenuListExtension', () => ({
+ __esModule: true,
+ default: ({
+ children,
+ viewId,
+ primary,
+ secondary,
+ defaultItems,
+ }: {
+ children?: React.ReactNode;
+ viewId: string;
+ primary?: boolean;
+ secondary?: boolean;
+ defaultItems?: MenuItemType[];
+ }) => (
+ <div
+ data-test="mock-menu-extension"
+ data-view-id={viewId}
+ data-primary={primary}
+ data-secondary={secondary}
+ data-default-items-count={defaultItems?.length ?? 0}
+ >
+ {children}
+ </div>
+ ),
+}));
+
+const defaultProps: SqlEditorTopBarProps = {
+ queryEditorId: 'test-query-editor-id',
+ defaultPrimaryActions: <button type="button">Primary Action</button>,
+ defaultSecondaryActions: [
+ { key: 'action1', label: 'Action 1' },
+ { key: 'action2', label: 'Action 2' },
+ ],
+};
+
+const setup = (props?: Partial<SqlEditorTopBarProps>) =>
+ render(<SqlEditorTopBar {...defaultProps} {...props} />);
+
+test('renders SqlEditorTopBar component', () => {
+ setup();
+ const menuExtensions = screen.getAllByTestId('mock-menu-extension');
+ expect(menuExtensions).toHaveLength(2);
+});
+
+test('renders primary MenuListExtension with correct props', () => {
+ 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');
+});
+
+test('renders secondary MenuListExtension with correct props', () => {
+ 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');
+});
+
+test('renders defaultPrimaryActions as children of primary MenuListExtension',
() => {
+ setup();
+ expect(
+ screen.getByRole('button', { name: 'Primary Action' }),
+ ).toBeInTheDocument();
+});
+
+test('renders with custom primary actions', () => {
+ const customPrimaryActions = (
+ <>
+ <button type="button">Custom Action 1</button>
+ <button type="button">Custom Action 2</button>
+ </>
+ );
+
+ setup({ defaultPrimaryActions: customPrimaryActions });
+
+ expect(
+ screen.getByRole('button', { name: 'Custom Action 1' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Custom Action 2' }),
+ ).toBeInTheDocument();
+});
+
+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');
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
new file mode 100644
index 0000000000..f8b7ead860
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx
@@ -0,0 +1,62 @@
+/**
+ * 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 { Divider, Flex } from '@superset-ui/core/components';
+import { styled } from '@apache-superset/core/ui';
+import { ViewContribution } from 'src/SqlLab/contributions';
+import MenuListExtension, {
+ type MenuListExtensionProps,
+} from 'src/components/MenuListExtension';
+
+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;
+ }
+`;
+export interface SqlEditorTopBarProps {
+ queryEditorId: string;
+ defaultPrimaryActions: React.ReactNode;
+ defaultSecondaryActions: MenuListExtensionProps['defaultItems'];
+}
+
+const SqlEditorTopBar = ({
+ defaultPrimaryActions,
+ defaultSecondaryActions,
+}: 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
+ viewId={ViewContribution.Editor}
+ secondary
+ defaultItems={defaultSecondaryActions}
+ />
+ <Divider type="vertical" />
+ </Flex>
+ </StyledFlex>
+);
+
+export default SqlEditorTopBar;
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts
new file mode 100644
index 0000000000..820f820be3
--- /dev/null
+++
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts
@@ -0,0 +1,320 @@
+/**
+ * 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 configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { renderHook, act } from '@testing-library/react-hooks';
+import { createWrapper } from 'spec/helpers/testing-library';
+import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
+import * as localStorageHelpers from 'src/utils/localStorageHelpers';
+
+import useDatabaseSelector from './useDatabaseSelector';
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+const mockDatabase = {
+ id: 1,
+ database_name: 'main',
+ backend: 'mysql',
+};
+
+const mockDatabases = {
+ [mockDatabase.id]: mockDatabase,
+};
+
+const createInitialState = (overrides = {}) => ({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ databases: mockDatabases,
+ ...overrides,
+ },
+});
+
+beforeEach(() => {
+ jest.spyOn(localStorageHelpers, 'getItem').mockReturnValue(null);
+ jest.spyOn(localStorageHelpers, 'setItem').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+});
+
+test('returns initial values from query editor', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ expect(result.current.catalog).toBe(defaultQueryEditor.catalog);
+ expect(result.current.schema).toBe(defaultQueryEditor.schema);
+ expect(typeof result.current.onDbChange).toBe('function');
+ expect(typeof result.current.onCatalogChange).toBe('function');
+ expect(typeof result.current.onSchemaChange).toBe('function');
+ expect(typeof result.current.getDbList).toBe('function');
+ expect(typeof result.current.handleError).toBe('function');
+});
+
+test('returns database when dbId exists in store', () => {
+ const store = mockStore(
+ createInitialState({
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ dbId: mockDatabase.id,
+ },
+ }),
+ );
+
+ const { result, rerender } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ // Trigger effect by rerendering
+ rerender();
+
+ expect(result.current.db).toEqual(mockDatabase);
+});
+
+test('dispatches QUERY_EDITOR_SETDB action on onDbChange', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.onDbChange({ id: 2 });
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'QUERY_EDITOR_SETDB',
+ dbId: 2,
+ }),
+ );
+});
+
+test('dispatches queryEditorSetCatalog action on onCatalogChange', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.onCatalogChange('new_catalog');
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'QUERY_EDITOR_SET_CATALOG',
+ }),
+ );
+});
+
+test('dispatches queryEditorSetSchema action on onSchemaChange', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.onSchemaChange('new_schema');
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'QUERY_EDITOR_SET_SCHEMA',
+ }),
+ );
+});
+
+test('dispatches setDatabases action on getDbList', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ const newDatabase = {
+ id: 3,
+ database_name: 'test_db',
+ backend: 'postgresql',
+ };
+
+ act(() => {
+ result.current.getDbList(newDatabase as any);
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'SET_DATABASES',
+ }),
+ );
+});
+
+test('dispatches addDangerToast action on handleError', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.handleError('Test error message');
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'ADD_TOAST',
+ payload: expect.objectContaining({
+ toastType: 'DANGER_TOAST',
+ text: 'Test error message',
+ }),
+ }),
+ );
+});
+
+test('reads database from localStorage when URL has db param', () => {
+ const localStorageDb = {
+ id: 5,
+ database_name: 'local_storage_db',
+ backend: 'sqlite',
+ };
+
+ jest.spyOn(localStorageHelpers, 'getItem').mockReturnValue(localStorageDb);
+
+ const originalLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ value: { search: '?db=true' },
+ writable: true,
+ });
+
+ const store = mockStore(createInitialState());
+ const { result, rerender } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ rerender();
+
+ expect(result.current.db).toEqual(localStorageDb);
+ expect(localStorageHelpers.setItem).toHaveBeenCalledWith(
+ localStorageHelpers.LocalStorageKeys.Database,
+ null,
+ );
+
+ Object.defineProperty(window, 'location', {
+ value: originalLocation,
+ writable: true,
+ });
+});
+
+test('returns null db when dbId does not exist in databases', () => {
+ const store = mockStore(
+ createInitialState({
+ databases: {},
+ }),
+ );
+
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ expect(result.current.db).toBeNull();
+});
+
+test('handles null catalog change', () => {
+ const store = mockStore(createInitialState());
+ const { result } = renderHook(
+ () => useDatabaseSelector(defaultQueryEditor.id),
+ {
+ wrapper: createWrapper({
+ useRedux: true,
+ store,
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.onCatalogChange(null);
+ });
+
+ const actions = store.getActions();
+ expect(actions).toContainEqual(
+ expect.objectContaining({
+ type: 'QUERY_EDITOR_SET_CATALOG',
+ }),
+ );
+});
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts
new file mode 100644
index 0000000000..984f7b2a89
--- /dev/null
+++
b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts
@@ -0,0 +1,126 @@
+/**
+ * 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 { useEffect, useCallback, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { SqlLabRootState } from 'src/SqlLab/types';
+import {
+ queryEditorSetDb,
+ queryEditorSetCatalog,
+ queryEditorSetSchema,
+ setDatabases,
+ addDangerToast,
+ type Database,
+} from 'src/SqlLab/actions/sqlLab';
+import { type DatabaseObject } from 'src/components';
+import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
+import {
+ getItem,
+ LocalStorageKeys,
+ setItem,
+} from 'src/utils/localStorageHelpers';
+
+export default function useDatabaseSelector(queryEditorId: string) {
+ const databases = useSelector<
+ SqlLabRootState,
+ SqlLabRootState['sqlLab']['databases']
+ >(({ sqlLab }) => sqlLab.databases);
+ const dispatch = useDispatch();
+ const queryEditor = useQueryEditor(queryEditorId, [
+ 'dbId',
+ 'catalog',
+ 'schema',
+ 'tabViewId',
+ ]);
+ const database = useMemo(
+ () => (queryEditor.dbId ? databases[queryEditor.dbId] : undefined),
+ [databases, queryEditor.dbId],
+ );
+ const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
+ null,
+ );
+ const { catalog, schema } = queryEditor;
+
+ const onDbChange = useCallback(
+ ({ id: dbId }: { id: number }) => {
+ if (queryEditor) {
+ dispatch(queryEditorSetDb(queryEditor, dbId));
+ }
+ },
+ [dispatch, queryEditor],
+ );
+
+ const handleCatalogChange = useCallback(
+ (catalog: string | null) => {
+ if (queryEditor) {
+ dispatch(queryEditorSetCatalog(queryEditor, catalog));
+ }
+ },
+ [dispatch, queryEditor],
+ );
+
+ const handleSchemaChange = useCallback(
+ (schema: string) => {
+ if (queryEditor) {
+ dispatch(queryEditorSetSchema(queryEditor, schema));
+ }
+ },
+ [dispatch, queryEditor],
+ );
+
+ const handleDbList = useCallback(
+ (result: DatabaseObject[]) => {
+ dispatch(setDatabases(result as unknown as Database[]));
+ },
+ [dispatch],
+ );
+
+ const handleError = useCallback(
+ (message: string) => {
+ dispatch(addDangerToast(message));
+ },
+ [dispatch],
+ );
+
+ useEffect(() => {
+ const bool = new URLSearchParams(window.location.search).get('db');
+ const userSelected = getItem(
+ LocalStorageKeys.Database,
+ null,
+ ) as DatabaseObject | null;
+
+ if (bool && userSelected) {
+ setUserSelected(userSelected);
+ setItem(LocalStorageKeys.Database, null);
+ } else if (database) {
+ setUserSelected(database);
+ }
+ }, [database]);
+
+ return {
+ db: userSelectedDb,
+ catalog,
+ schema,
+ getDbList: handleDbList,
+ handleError,
+ onDbChange,
+ onCatalogChange: handleCatalogChange,
+ onSchemaChange: handleSchemaChange,
+ };
+}
diff --git a/superset-frontend/src/SqlLab/contributions.ts
b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
similarity index 52%
copy from superset-frontend/src/SqlLab/contributions.ts
copy to superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
index 70f00f4d07..3cf0ea3fb3 100644
--- a/superset-frontend/src/SqlLab/contributions.ts
+++ b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
@@ -16,7 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
-export enum ViewContribution {
- RightSidebar = 'sqllab.rightSidebar',
- SouthPanels = 'sqllab.panels',
-}
+import { render, screen } from 'spec/helpers/testing-library';
+import StatusBar from 'src/SqlLab/components/StatusBar';
+
+jest.mock('src/extensions/ExtensionsManager', () => {
+ const getInstance = jest.fn().mockReturnValue({
+ getViewContributions: jest
+ .fn()
+ .mockReturnValue([{ id: 'test-status-bar' }]),
+ });
+ return { getInstance };
+});
+
+jest.mock('src/components/ViewListExtension', () => ({
+ __esModule: true,
+ default: ({ viewId }: { viewId: string }) => (
+ <div data-test="mock-view-extension" data-view-id={viewId}>
+ ViewListExtension
+ </div>
+ ),
+}));
+
+test('renders StatusBar component', () => {
+ render(<StatusBar />);
+ expect(screen.getByTestId('mock-view-extension')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/SqlLab/components/StatusBar/index.tsx
b/superset-frontend/src/SqlLab/components/StatusBar/index.tsx
new file mode 100644
index 0000000000..97ce1e7fbd
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/StatusBar/index.tsx
@@ -0,0 +1,57 @@
+/**
+ * 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 { styled } from '@apache-superset/core';
+import { Flex } from '@superset-ui/core/components';
+import ViewListExtension from 'src/components/ViewListExtension';
+import ExtensionsManager from 'src/extensions/ExtensionsManager';
+import { SQL_EDITOR_STATUSBAR_HEIGHT } from 'src/SqlLab/constants';
+import { ViewContribution } from 'src/SqlLab/contributions';
+
+const Container = styled(Flex)`
+ flex-direction: row-reverse;
+ height: ${SQL_EDITOR_STATUSBAR_HEIGHT}px;
+ background-color: ${({ theme }) => theme.colorPrimary};
+ color: ${({ theme }) => theme.colorWhite};
+ padding: 0 ${({ theme }) => theme.sizeUnit * 4}px;
+
+ & .ant-tag {
+ color: ${({ theme }) => theme.colorWhite};
+ background-color: transparent;
+ border: 0;
+ }
+`;
+
+const StatusBar = () => {
+ const statusBarContributions =
+ ExtensionsManager.getInstance().getViewContributions(
+ ViewContribution.StatusBar,
+ ) || [];
+
+ return (
+ <>
+ {statusBarContributions.length > 0 && (
+ <Container align="center" justify="space-between">
+ <ViewListExtension viewId={ViewContribution.StatusBar} />
+ </Container>
+ )}
+ </>
+ );
+};
+
+export default StatusBar;
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
index 9a78a116bb..83c870e123 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
@@ -42,6 +42,42 @@ const StyledEditableTabs = styled(EditableTabs)`
height: 100%;
display: flex;
flex-direction: column;
+ & .ant-tabs-nav::before {
+ border-color: ${({ theme }) => theme.colorBorder} !important;
+ }
+ & .ant-tabs-nav-add {
+ border-color: ${({ theme }) => theme.colorBorder} !important;
+ height: 34px;
+ }
+ & .ant-tabs-nav-list {
+ align-items: end;
+ padding-top: 1px;
+ column-gap: ${({ theme }) => theme.sizeUnit}px;
+ }
+ & .ant-tabs-tab-active {
+ border-left-color: ${({ theme }) => theme.colorPrimaryActive} !important;
+ border-top-color: ${({ theme }) => theme.colorPrimaryActive} !important;
+ border-right-color: ${({ theme }) => theme.colorPrimaryActive} !important;
+ box-shadow: 0 0 2px ${({ theme }) => theme.colorPrimaryActive} !important;
+ border-top: 2px;
+ }
+ & .ant-tabs-tab {
+ border-radius: 2px 2px 0px 0px !important;
+ padding: ${({ theme }) => theme.sizeUnit}px
+ ${({ theme }) => theme.sizeUnit * 2}px !important;
+ & + .ant-tabs-nav-add {
+ margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
+ }
+ &:not(.ant-tabs-tab-active) {
+ border-color: ${({ theme }) => theme.colorBorder} !important;
+ box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder} !important;
+ }
+ }
+ & .ant-tabs-nav-add {
+ border-radius: 2px 2px 0px 0px !important;
+ min-height: auto !important;
+ align-self: flex-end;
+ }
`;
const StyledTab = styled.span`
@@ -198,14 +234,14 @@ class TabbedSqlEditors extends
PureComponent<TabbedSqlEditorsProps> {
addIcon={
<Tooltip
id="add-tab"
- placement="bottom"
+ placement="left"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
- <Icons.PlusCircleOutlined
+ <Icons.PlusOutlined
iconSize="l"
css={css`
vertical-align: middle;
diff --git a/superset-frontend/src/SqlLab/constants.ts
b/superset-frontend/src/SqlLab/constants.ts
index 3b656c3a46..2158af3c45 100644
--- a/superset-frontend/src/SqlLab/constants.ts
+++ b/superset-frontend/src/SqlLab/constants.ts
@@ -66,9 +66,10 @@ export const TIME_OPTIONS = [
];
// SqlEditor layout constants
-export const SQL_EDITOR_GUTTER_HEIGHT = 4;
+export const SQL_EDITOR_GUTTER_HEIGHT = 5;
export const SQL_EDITOR_LEFTBAR_WIDTH = 400;
export const SQL_EDITOR_RIGHTBAR_WIDTH = 400;
+export const SQL_EDITOR_STATUSBAR_HEIGHT = 30;
export const INITIAL_NORTH_PERCENT = 30;
export const SET_QUERY_EDITOR_SQL_DEBOUNCE_MS = 2000;
export const VALIDATION_DEBOUNCE_MS = 600;
diff --git a/superset-frontend/src/SqlLab/contributions.ts
b/superset-frontend/src/SqlLab/contributions.ts
index 70f00f4d07..b9549bed28 100644
--- a/superset-frontend/src/SqlLab/contributions.ts
+++ b/superset-frontend/src/SqlLab/contributions.ts
@@ -18,5 +18,7 @@
*/
export enum ViewContribution {
RightSidebar = 'sqllab.rightSidebar',
- SouthPanels = 'sqllab.panels',
+ Panels = 'sqllab.panels',
+ Editor = 'sqllab.editor',
+ StatusBar = 'sqllab.statusBar',
}
diff --git
a/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
b/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
new file mode 100644
index 0000000000..853c58c8aa
--- /dev/null
+++
b/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx
@@ -0,0 +1,374 @@
+/**
+ * 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
new file mode 100644
index 0000000000..98608f6a29
--- /dev/null
+++ b/superset-frontend/src/components/MenuListExtension/index.tsx
@@ -0,0 +1,157 @@
+/**
+ * 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/ViewListExtension/ViewListExtension.test.tsx
b/superset-frontend/src/components/ViewListExtension/ViewListExtension.test.tsx
new file mode 100644
index 0000000000..906c1d9151
--- /dev/null
+++
b/superset-frontend/src/components/ViewListExtension/ViewListExtension.test.tsx
@@ -0,0 +1,198 @@
+/**
+ * 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 { ReactElement } from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+import type { contributions, core } from '@apache-superset/core';
+import ExtensionsManager from 'src/extensions/ExtensionsManager';
+import { ExtensionsProvider } from 'src/extensions/ExtensionsContext';
+import ViewListExtension from '.';
+
+function createMockView(
+ id: string,
+ overrides: Partial<contributions.ViewContribution> = {},
+): contributions.ViewContribution {
+ return {
+ id,
+ name: `${id} View`,
+ ...overrides,
+ };
+}
+
+function createMockExtension(
+ options: Partial<core.Extension> & {
+ views?: Record<string, contributions.ViewContribution[]>;
+ } = {},
+): core.Extension {
+ const {
+ id = 'test-extension',
+ name = 'Test Extension',
+ views = {},
+ } = options;
+
+ return {
+ id,
+ name,
+ description: 'A test extension',
+ version: '1.0.0',
+ dependencies: [],
+ remoteEntry: '',
+ exposedModules: [],
+ extensionDependencies: [],
+ contributions: {
+ commands: [],
+ 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.view';
+
+const renderWithExtensionsProvider = (ui: ReactElement) => {
+ return render(ui, { wrapper: ExtensionsProvider as any });
+};
+
+beforeEach(() => {
+ (ExtensionsManager as any).instance = undefined;
+});
+
+afterEach(() => {
+ (ExtensionsManager as any).instance = undefined;
+});
+
+test('renders nothing when no view contributions exist', () => {
+ const { container } = renderWithExtensionsProvider(
+ <ViewListExtension viewId={TEST_VIEW_ID} />,
+ );
+
+ expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
+});
+
+test('renders placeholder for unregistered view provider', async () => {
+ const manager = ExtensionsManager.getInstance();
+
+ await createActivatedExtension(manager, {
+ views: {
+ [TEST_VIEW_ID]: [createMockView('test-view-1')],
+ },
+ });
+
+ renderWithExtensionsProvider(<ViewListExtension viewId={TEST_VIEW_ID} />);
+
+ expect(screen.getByText(/test-view-1/)).toBeInTheDocument();
+});
+
+test('renders multiple view placeholders for multiple contributions', async ()
=> {
+ const manager = ExtensionsManager.getInstance();
+
+ await createActivatedExtension(manager, {
+ views: {
+ [TEST_VIEW_ID]: [
+ createMockView('test-view-1'),
+ createMockView('test-view-2'),
+ ],
+ },
+ });
+
+ renderWithExtensionsProvider(<ViewListExtension viewId={TEST_VIEW_ID} />);
+
+ expect(screen.getByText(/test-view-1/)).toBeInTheDocument();
+ expect(screen.getByText(/test-view-2/)).toBeInTheDocument();
+});
+
+test('renders nothing for viewId with no matching contributions', () => {
+ const { container } = renderWithExtensionsProvider(
+ <ViewListExtension viewId="nonexistent.view" />,
+ );
+
+ expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
+});
+
+test('handles multiple extensions with views for same viewId', async () => {
+ const manager = ExtensionsManager.getInstance();
+
+ await createActivatedExtension(manager, {
+ id: 'extension-1',
+ views: {
+ [TEST_VIEW_ID]: [createMockView('ext1-view')],
+ },
+ });
+
+ await createActivatedExtension(manager, {
+ id: 'extension-2',
+ views: {
+ [TEST_VIEW_ID]: [createMockView('ext2-view')],
+ },
+ });
+
+ renderWithExtensionsProvider(<ViewListExtension viewId={TEST_VIEW_ID} />);
+
+ expect(screen.getByText(/ext1-view/)).toBeInTheDocument();
+ expect(screen.getByText(/ext2-view/)).toBeInTheDocument();
+});
+
+test('renders views for different viewIds independently', async () => {
+ const manager = ExtensionsManager.getInstance();
+ const VIEW_ID_A = 'view.a';
+ const VIEW_ID_B = 'view.b';
+
+ await createActivatedExtension(manager, {
+ views: {
+ [VIEW_ID_A]: [createMockView('view-a-component')],
+ [VIEW_ID_B]: [createMockView('view-b-component')],
+ },
+ });
+
+ const { rerender } = renderWithExtensionsProvider(
+ <ViewListExtension viewId={VIEW_ID_A} />,
+ );
+
+ expect(screen.getByText(/view-a-component/)).toBeInTheDocument();
+ expect(screen.queryByText(/view-b-component/)).not.toBeInTheDocument();
+
+ rerender(<ViewListExtension viewId={VIEW_ID_B} />);
+
+ expect(screen.getByText(/view-b-component/)).toBeInTheDocument();
+ expect(screen.queryByText(/view-a-component/)).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/SqlLab/contributions.ts
b/superset-frontend/src/components/ViewListExtension/index.tsx
similarity index 51%
copy from superset-frontend/src/SqlLab/contributions.ts
copy to superset-frontend/src/components/ViewListExtension/index.tsx
index 70f00f4d07..a7f5209356 100644
--- a/superset-frontend/src/SqlLab/contributions.ts
+++ b/superset-frontend/src/components/ViewListExtension/index.tsx
@@ -16,7 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
-export enum ViewContribution {
- RightSidebar = 'sqllab.rightSidebar',
- SouthPanels = 'sqllab.panels',
+import ExtensionsManager from 'src/extensions/ExtensionsManager';
+import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
+
+export interface ViewListExtensionProps {
+ viewId: string;
}
+
+const ViewListExtension = ({ viewId }: ViewListExtensionProps) => {
+ const maybeContributions =
+ ExtensionsManager.getInstance().getViewContributions(viewId);
+ const contributions = Array.isArray(maybeContributions)
+ ? maybeContributions
+ : [];
+ const { getView } = useExtensionsContext();
+
+ return (
+ <>
+ {contributions
+ .filter(
+ contribution =>
+ contribution && typeof contribution.id !== 'undefined',
+ )
+ .map(contribution => getView(contribution.id))}
+ </>
+ );
+};
+
+export default ViewListExtension;