mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(sqllab): primary/secondary action extensions (#36644)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,6 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
|
||||
|
||||
const defaultProps = {
|
||||
queryEditorId: defaultQueryEditor.id,
|
||||
allowAsync: false,
|
||||
dbId: 1,
|
||||
queryState: 'ready',
|
||||
runQuery: () => {},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
57
superset-frontend/src/SqlLab/components/StatusBar/index.tsx
Normal file
57
superset-frontend/src/SqlLab/components/StatusBar/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,5 +18,7 @@
|
||||
*/
|
||||
export enum ViewContribution {
|
||||
RightSidebar = 'sqllab.rightSidebar',
|
||||
SouthPanels = 'sqllab.panels',
|
||||
Panels = 'sqllab.panels',
|
||||
Editor = 'sqllab.editor',
|
||||
StatusBar = 'sqllab.statusBar',
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
157
superset-frontend/src/components/MenuListExtension/index.tsx
Normal file
157
superset-frontend/src/components/MenuListExtension/index.tsx
Normal file
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
46
superset-frontend/src/components/ViewListExtension/index.tsx
Normal file
46
superset-frontend/src/components/ViewListExtension/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 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;
|
||||
Reference in New Issue
Block a user