feat(sqllab): primary/secondary action extensions (#36644)

This commit is contained in:
JUST.in DO IT
2026-01-12 12:06:15 -08:00
committed by GitHub
parent fa3d4a75ca
commit 7503ee4e09
32 changed files with 1840 additions and 388 deletions

View File

@@ -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.

View File

@@ -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>
);
};

View File

@@ -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();
});

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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>
);

View File

@@ -38,7 +38,6 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
const defaultProps = {
queryEditorId: defaultQueryEditor.id,
allowAsync: false,
dbId: 1,
queryState: 'ready',
runQuery: () => {},

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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

View File

@@ -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" />}
/>
);
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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',
}),
);
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,43 @@
/**
* 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 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();
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -18,5 +18,7 @@
*/
export enum ViewContribution {
RightSidebar = 'sqllab.rightSidebar',
SouthPanels = 'sqllab.panels',
Panels = 'sqllab.panels',
Editor = 'sqllab.editor',
StatusBar = 'sqllab.statusBar',
}