diff --git a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx index 368cc02c7a1..002d65cd070 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx @@ -273,10 +273,8 @@ export function AsyncAceEditor( key="ace-tooltip-global" styles={css` .ace_editor { - border: 1px solid ${token.colorBorder} !important; background-color: ${token.colorBgContainer} !important; } - /* Basic editor styles with dark mode support */ .ace_editor.ace-github, .ace_editor.ace-tm { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 6f9222470c9..319227687b1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -114,6 +114,7 @@ import { ShareAltOutlined, StarOutlined, StarFilled, + StepForwardOutlined, StopOutlined, SunOutlined, SyncOutlined, @@ -258,6 +259,7 @@ const AntdIcons = { SunOutlined, StarOutlined, StarFilled, + StepForwardOutlined, StopOutlined, SyncOutlined, TagOutlined, diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx index bd01735f756..0a9231e26a9 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx @@ -82,6 +82,7 @@ const AceEditorWrapper = ({ const currentSql = queryEditor.sql ?? ''; const [sql, setSql] = useState(currentSql); + const theme = useTheme(); // The editor changeSelection is called multiple times in a row, // faster than React reconciliation process, so the selected text @@ -126,7 +127,8 @@ const AceEditorWrapper = ({ exec: keyConfig.func, }); }); - + const marginSize = theme.sizeUnit * 2; + editor.renderer.setScrollMargin(marginSize, marginSize, 0, 0); editor.$blockScrolling = Infinity; // eslint-disable-line no-param-reassign editor.selection.on('changeSelection', () => { const selectedText = editor.getSelectedText(); @@ -178,7 +180,6 @@ const AceEditorWrapper = ({ }, !autocomplete, ); - const theme = useTheme(); return ( <> @@ -188,6 +189,27 @@ const AceEditorWrapper = ({ width: 100% !important; } + .ace_content, + .SqlEditor .sql-container .ace_gutter { + background-color: ${theme.colorBgBase} !important; + } + + .ace_gutter::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: ${theme.sizeUnit * 2}px; + width: 1px; + height: 100%; + background-color: ${theme.colorBorder}; + } + + .ace_gutter, + .ace_scroller { + background-color: ${theme.colorBgBase} !important; + } + .ace_autocomplete { // Use !important because Ace Editor applies extra CSS at the last second // when opening the autocomplete. diff --git a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx index 8d8f06d34ec..bb86e631eb0 100644 --- a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx +++ b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx @@ -19,11 +19,10 @@ import { useSelector } from 'react-redux'; import { noop } from 'lodash'; import type { SqlLabRootState } from 'src/SqlLab/types'; -import { styled } from '@apache-superset/core'; +import { css, styled } from '@apache-superset/core'; import { useComponentDidUpdate } from '@superset-ui/core'; import { Grid } from '@superset-ui/core/components'; import ExtensionsManager from 'src/extensions/ExtensionsManager'; -import { useExtensionsContext } from 'src/extensions/ExtensionsContext'; import { Splitter } from 'src/components/Splitter'; import useEffectEvent from 'src/hooks/useEffectEvent'; import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth'; @@ -31,11 +30,15 @@ import { SQL_EDITOR_LEFTBAR_WIDTH, SQL_EDITOR_RIGHTBAR_WIDTH, } from 'src/SqlLab/constants'; +import { ViewContribution } from 'src/SqlLab/contributions'; +import ViewListExtension from 'src/components/ViewListExtension'; import SqlEditorLeftBar from '../SqlEditorLeftBar'; -import { ViewContribution } from 'src/SqlLab/contributions'; +import StatusBar from '../StatusBar'; const StyledContainer = styled.div` + display: flex; + flex-direction: column; height: 100%; & .ant-splitter-panel:not(.sqllab-body):not(.queryPane) { @@ -93,11 +96,17 @@ const AppLayout: React.FC = ({ children }) => { ExtensionsManager.getInstance().getViewContributions( ViewContribution.RightSidebar, ) || []; - const { getView } = useExtensionsContext(); return ( - + { min={SQL_EDITOR_RIGHTBAR_WIDTH} > - {contributions.map(contribution => getView(contribution.id))} + )} + ); }; diff --git a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx index 684aef7b06e..cda93761ff8 100644 --- a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx @@ -56,22 +56,24 @@ const setup = (props: Partial, store?: Store) => // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('EstimateQueryCostButton', () => { test('renders EstimateQueryCostButton', async () => { - const { queryByText } = setup({}, mockStore(initialState)); + const { queryByLabelText } = setup({}, mockStore(initialState)); - expect(queryByText('Estimate cost')).toBeInTheDocument(); + expect(queryByLabelText('Estimate cost')).toBeInTheDocument(); }); test('renders label for selected query', async () => { - const { queryByText } = setup( + const { queryByLabelText } = setup( { queryEditorId: extraQueryEditor1.id }, mockStore(initialState), ); - expect(queryByText('Estimate selected query cost')).toBeInTheDocument(); + expect( + queryByLabelText('Estimate selected query cost'), + ).toBeInTheDocument(); }); test('renders label for selected query from unsaved', async () => { - const { queryByText } = setup( + const { queryByLabelText } = setup( {}, mockStore({ ...initialState, @@ -85,11 +87,13 @@ describe('EstimateQueryCostButton', () => { }), ); - expect(queryByText('Estimate selected query cost')).toBeInTheDocument(); + expect( + queryByLabelText('Estimate selected query cost'), + ).toBeInTheDocument(); }); test('renders estimation error result', async () => { - const { queryByText, getByText } = setup( + const { queryByLabelText, queryByText, getByLabelText } = setup( {}, mockStore({ ...initialState, @@ -104,14 +108,14 @@ describe('EstimateQueryCostButton', () => { }), ); - expect(queryByText('Estimate cost')).toBeInTheDocument(); - fireEvent.click(getByText('Estimate cost')); + expect(queryByLabelText('Estimate cost')).toBeInTheDocument(); + fireEvent.click(getByLabelText('Estimate cost')); expect(queryByText('Estimate error')).toBeInTheDocument(); }); test('renders estimation success result', async () => { - const { queryByText, getByText, findByTitle } = setup( + const { queryByLabelText, getByLabelText, findByTitle } = setup( {}, mockStore({ ...initialState, @@ -127,8 +131,8 @@ describe('EstimateQueryCostButton', () => { }), ); - expect(queryByText('Estimate cost')).toBeInTheDocument(); - fireEvent.click(getByText('Estimate cost')); + expect(queryByLabelText('Estimate cost')).toBeInTheDocument(); + fireEvent.click(getByLabelText('Estimate cost')); const totalCostTitle = await findByTitle('Total cost'); expect(totalCostTitle).toBeInTheDocument(); }); diff --git a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx index 227ff354ea1..621020c410d 100644 --- a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx @@ -27,6 +27,7 @@ import { ModalTrigger, TableView, EmptyWrapperType, + Icons, } from '@superset-ui/core/components'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import { SqlLabRootState, QueryCostEstimate } from 'src/SqlLab/types'; @@ -111,14 +112,16 @@ const EstimateQueryCostButton = ({ modalBody={renderModalBody()} triggerNode={ + icon={} + aria-label={btnText} + /> } /> diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx b/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx index 0544f49299e..73b90259587 100644 --- a/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/QueryLimitSelect.test.tsx @@ -30,6 +30,7 @@ import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures'; import QueryLimitSelect, { QueryLimitSelectProps, convertToNumWithSpaces, + convertToShortNum, } from 'src/SqlLab/components/QueryLimitSelect'; const middlewares = [thunk]; @@ -102,7 +103,7 @@ describe('QueryLimitSelect', () => { }, }), ); - expect(getByText(convertToNumWithSpaces(queryLimit))).toBeInTheDocument(); + expect(getByText(convertToShortNum(queryLimit))).toBeInTheDocument(); }); test('renders dropdown select', async () => { diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx index 59e9ba93bfb..9b63b494df7 100644 --- a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx @@ -34,6 +34,19 @@ export function convertToNumWithSpaces(num: number) { return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 '); } +export function convertToShortNum(num: number) { + if (num < 1000) { + return num; + } + if (num < 1_000_000) { + return `${num / 1000}K`; + } + if (num < 1_000_000_000) { + return `${num / 1000_000}M`; + } + return num; +} + function renderQueryLimit( maxRow: number, setQueryLimit: (limit: number) => void, @@ -74,12 +87,15 @@ const QueryLimitSelect = ({ popupRender={() => renderQueryLimit(maxRow, setQueryLimit)} trigger={['click']} > - ); diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx index 2ac34b4bb7d..61b56137929 100644 --- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx @@ -38,7 +38,6 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => ( const defaultProps = { queryEditorId: defaultQueryEditor.id, - allowAsync: false, dbId: 1, queryState: 'ready', runQuery: () => {}, diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx index 5c2e8fa6f56..9bc54bb2687 100644 --- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx @@ -33,10 +33,10 @@ import { import useLogAction from 'src/logger/useLogAction'; export interface RunQueryActionButtonProps { + compactMode?: boolean; queryEditorId: string; - allowAsync: boolean; queryState?: string; - runQuery: (c?: boolean) => void; + runQuery: () => void; stopQuery: () => void; overlayCreateAsMenu: ReactElement | null; } @@ -47,13 +47,14 @@ const buildTextAndIcon = ( theme: SupersetTheme, ): { text: string; icon?: IconType } => { let text = t('Run'); - let icon: IconType | undefined; + let icon: IconType | undefined = ; if (selectedText) { text = t('Run selection'); + icon = ; } if (shouldShowStopButton) { text = t('Stop'); - icon = ; + icon = ; } 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) => 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 = ({ - 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} diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx index cd78007a6e4..faaa5cb67fc 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/SaveDatasetActionButton.test.tsx @@ -16,50 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { render, screen, userEvent } from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { render, screen } from 'spec/helpers/testing-library'; import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton'; -const overlayMenu = ( - -); - // 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( 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( - true} - overlayMenu={overlayMenu} - />, - ); - - const caretBtn = screen.getByRole('button', { name: /down/i }); - expect( - await screen.findByRole('button', { name: /down/i }), - ).toBeInTheDocument(); - userEvent.click(caretBtn); - - const saveDatasetMenuItem = screen.getByText(/save dataset/i); - - expect(saveDatasetMenuItem).toBeInTheDocument(); + expect(saveDatasetBtn).toBeVisible(); }); }); diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx index e1891b77fce..a68edb7efaa 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetActionButton/index.tsx @@ -17,37 +17,38 @@ * under the License. */ import { t } from '@apache-superset/core'; -import { useTheme } from '@apache-superset/core/ui'; import { Icons } from '@superset-ui/core/components/Icons'; -import { Button, DropdownButton } from '@superset-ui/core/components'; +import { Button } from '@superset-ui/core/components'; interface SaveDatasetActionButtonProps { setShowSave: (arg0: boolean) => void; - overlayMenu: JSX.Element | null; + onSaveAsExplore?: () => void; } const SaveDatasetActionButton = ({ setShowSave, - overlayMenu, -}: SaveDatasetActionButtonProps) => { - const theme = useTheme(); - - return !overlayMenu ? ( - - ) : ( - ( + <> + + icon={} + /> ); }; diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 529cdaec778..1f4697db6b8 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -25,9 +25,11 @@ import { css, styled, useTheme } from '@apache-superset/core/ui'; import { removeTables, setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab'; -import { Label } from '@superset-ui/core/components'; +import { Flex, Label } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { SqlLabRootState } from 'src/SqlLab/types'; +import { ViewContribution } from 'src/SqlLab/contributions'; +import MenuListExtension from 'src/components/MenuListExtension'; import { useExtensionsContext } from 'src/extensions/ExtensionsContext'; import ExtensionsManager from 'src/extensions/ExtensionsManager'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; @@ -41,7 +43,6 @@ import { } from '../../constants'; import Results from './Results'; import TablePreview from '../TablePreview'; -import { ViewContribution } from 'src/SqlLab/contributions'; /* editorQueries are queries executed by users passed from SqlEditor component @@ -73,6 +74,10 @@ const StyledPane = styled.div` overflow-y: auto; } } + .ant-tabs-extra-content { + margin: 0 ${({ theme }) => theme.sizeUnit * 4}px + ${({ theme }) => theme.sizeUnit * 2}px; + } .ant-tabs-tabpane { .scrollable { overflow-y: auto; @@ -101,7 +106,7 @@ const SouthPane = ({ const dispatch = useDispatch(); const contributions = ExtensionsManager.getInstance().getViewContributions( - ViewContribution.SouthPanels, + ViewContribution.Panels, ) || []; const { getView } = useExtensionsContext(); const { offline, tables } = useSelector( @@ -219,6 +224,18 @@ const SouthPane = ({ return ( + + + + ), + }} type="editable-card" activeKey={pinnedTableKeys[activeSouthPaneTab] || activeSouthPaneTab} className="SouthPaneTabs" diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx index b0b7cd03e27..b15bbdadc46 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx @@ -321,7 +321,7 @@ describe('SqlEditor', () => { const defaultQueryLimit = 101; const updatedProps = { ...mockedProps, defaultQueryLimit }; const { findByText } = setup(updatedProps, store); - fireEvent.click(await findByText('LIMIT:')); + fireEvent.click(await findByText('Limit')); expect(await findByText('10 000')).toBeInTheDocument(); }); @@ -382,8 +382,8 @@ describe('SqlEditor', () => { }, }, }); - const { findByText } = setup(mockedProps, store); - const button = await findByText('Estimate cost'); + const { findByLabelText } = setup(mockedProps, store); + const button = await findByLabelText('Estimate cost'); expect(button).toBeInTheDocument(); // click button diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 792cc0a9adc..8426a3f9811 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -51,17 +51,15 @@ import { debounce, isEmpty } from 'lodash'; import Mousetrap from 'mousetrap'; import { Button, - Dropdown, + Divider, EmptyState, Input, Modal, - Timer, } from '@superset-ui/core/components'; import { Splitter } from 'src/components/Splitter'; import { Skeleton } from '@superset-ui/core/components/Skeleton'; import { Switch } from '@superset-ui/core/components/Switch'; import { Menu, MenuItemType } from '@superset-ui/core/components/Menu'; -import { Icons } from '@superset-ui/core/components/Icons'; import { detectOS } from 'src/utils/common'; import { addNewQueryEditor, @@ -85,7 +83,6 @@ import { switchQueryEditor, } from 'src/SqlLab/actions/sqlLab'; import { - STATE_TYPE_MAP, SQL_EDITOR_GUTTER_HEIGHT, INITIAL_NORTH_PERCENT, SET_QUERY_EDITOR_SQL_DEBOUNCE_MS, @@ -107,8 +104,6 @@ import { LOG_ACTIONS_SQLLAB_STOP_QUERY, Logger, } from 'src/logger/LogUtils'; -import ExtensionsManager from 'src/extensions/ExtensionsManager'; -import { commands } from 'src/core'; import { CopyToClipboard } from 'src/components'; import TemplateParamsEditor from '../TemplateParamsEditor'; import SouthPane from '../SouthPane'; @@ -123,6 +118,7 @@ import KeyboardShortcutButton, { KEY_MAP, KeyboardShortcut, } from '../KeyboardShortcutButton'; +import SqlEditorTopBar from '../SqlEditorTopBar'; const bootstrapData = getBootstrapData(); const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES; @@ -166,34 +162,56 @@ const StyledSqlEditor = styled.div` height: 100%; .queryPane { - padding: ${theme.sizeUnit * 2}px 0px; + padding: 0; + .ant-splitter-bar .ant-splitter-bar-dragger { &::before { - background: transparent; + height: 1px; + background-color: ${theme.colorBorder}; + transform: translateX(-50%) !important; } &::after { height: ${SQL_EDITOR_GUTTER_HEIGHT}px; background: transparent; border-top: 1px solid ${theme.colorBorder}; border-bottom: 1px solid ${theme.colorBorder}; + transform: translate(-50%, -2px); } } } .north-pane { + padding: ${theme.sizeUnit * 2}px 0 0 0; height: 100%; margin: 0 ${theme.sizeUnit * 4}px; } - .SouthPane .ant-tabs-tabpane { - margin: 0 ${theme.sizeUnit * 4}px; - & .ant-tabs { - margin: 0 ${theme.sizeUnit * -4}px; + .SouthPane { + & .ant-tabs-tabpane { + margin: 0 ${theme.sizeUnit * 4}px; + & .ant-tabs { + margin: 0 ${theme.sizeUnit * -4}px; + } + } + & .ant-tabs-tab { + box-shadow: none !important; + background: transparent !important; + border-color: transparent !important; + margin-top: ${theme.sizeUnit * 2}px !important; + &.ant-tabs-tab-active { + border-bottom-color: ${theme.colorPrimary} !important; + & .ant-tabs-tab-btn { + font-weight: ${theme.fontWeightStrong}; + color: ${theme.colorTextBase} !important; + text-shadow: none !important; + } + } } } .sql-container { flex: 1 1 auto; + margin: 0 ${theme.sizeUnit * -4}px; + box-shadow: 0 0 0 1px ${theme.colorBorder}; } `} `; @@ -615,30 +633,13 @@ const SqlEditor: FC = ({ 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 = ({ ), }, - ...secondaryContributions, ].filter(Boolean) as MenuItemType[]; - return ; + return menuItems; }; const onSaveQuery = async (query: QueryPayload, clientId: string) => { @@ -721,34 +721,8 @@ const SqlEditor: FC = ({ 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 ( - - ); - }, - ); - const showMenu = allowCTAS || allowCVAS; const menuItems: MenuItemType[] = [ allowCTAS && { @@ -778,93 +752,62 @@ const SqlEditor: FC = ({ const runMenuBtn = ; return ( - - {hideActions ? ( - + + + - ) : ( - <> -
- - - - {isFeatureEnabled(FeatureFlag.EstimateQueryCost) && - database?.allows_cost_estimate && ( - - - - )} - - - - {latestQuery && ( - - )} -
-
- - - dispatch(updateSavedQuery(query, remoteId)) - } - saveQueryWarning={saveQueryWarning} - database={database} - /> - - - - -
{primaryContributions}
- renderDropdown()} - trigger={['click']} - > - - -
- - )} -
+ + + {isFeatureEnabled(FeatureFlag.EstimateQueryCost) && + database?.allows_cost_estimate && ( + + + + )} + + dispatch(updateSavedQuery(query, remoteId)) + } + saveQueryWarning={saveQueryWarning} + database={database} + /> + + ); }; + const renderEmptyAlert = () => ( + + + + ); + const handleCursorPositionChange = (newPosition: CursorPosition) => { dispatch(queryEditorSetCursorPosition(queryEditor, newPosition)); }; @@ -950,13 +893,13 @@ const SqlEditor: FC = ({ className="queryPane" >
- {SqlFormExtension && ( - )} {queryEditor.isDataset && renderDatasetWarning()} @@ -977,7 +920,15 @@ const SqlEditor: FC = ({ }
- {renderEditorBottomBar(showEmptyState)} + {SqlFormExtension && ( + + )} diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index 7d9a025864e..5010298da0a 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -16,36 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { SqlLabRootState, Table } from 'src/SqlLab/types'; import { - queryEditorSetDb, addTable, removeTables, collapseTable, expandTable, - queryEditorSetCatalog, - queryEditorSetSchema, - setDatabases, - addDangerToast, resetState, - type Database, } from 'src/SqlLab/actions/sqlLab'; import { Button, EmptyState, Icons } from '@superset-ui/core/components'; -import { type DatabaseObject } from 'src/components'; import { t } from '@apache-superset/core'; import { styled, css } from '@apache-superset/core/ui'; import { TableSelectorMultiple } from 'src/components/TableSelector'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; -import { - getItem, - LocalStorageKeys, - setItem, -} from 'src/utils/localStorageHelpers'; import { noop } from 'lodash'; import TableElement from '../TableElement'; +import useDatabaseSelector from '../SqlEditorTopBar/useDatabaseSelector'; export interface SqlEditorLeftBarProps { queryEditorId: string; @@ -70,10 +59,8 @@ const LeftBarStyles = styled.div` `; const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => { - const databases = useSelector< - SqlLabRootState, - SqlLabRootState['sqlLab']['databases'] - >(({ sqlLab }) => sqlLab.databases); + const { db: userSelectedDb, ...dbSelectorProps } = + useDatabaseSelector(queryEditorId); const allSelectedTables = useSelector( ({ 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( - 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 ( } database={userSelectedDb} - getDbList={handleDbList} - handleError={handleError} - onDbChange={onDbChange} - onCatalogChange={handleCatalogChange} - catalog={catalog} - onSchemaChange={handleSchemaChange} - schema={schema} onTableSelectChange={onTablesChange} tableValue={selectedTableNames} sqlLabMode diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx new file mode 100644 index 00000000000..d5a6871d52b --- /dev/null +++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx @@ -0,0 +1,130 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen } from 'spec/helpers/testing-library'; +import { MenuItemType } from '@superset-ui/core/components/Menu'; +import SqlEditorTopBar, { + SqlEditorTopBarProps, +} from 'src/SqlLab/components/SqlEditorTopBar'; + +jest.mock('src/components/MenuListExtension', () => ({ + __esModule: true, + default: ({ + children, + viewId, + primary, + secondary, + defaultItems, + }: { + children?: React.ReactNode; + viewId: string; + primary?: boolean; + secondary?: boolean; + defaultItems?: MenuItemType[]; + }) => ( +
+ {children} +
+ ), +})); + +const defaultProps: SqlEditorTopBarProps = { + queryEditorId: 'test-query-editor-id', + defaultPrimaryActions: , + defaultSecondaryActions: [ + { key: 'action1', label: 'Action 1' }, + { key: 'action2', label: 'Action 2' }, + ], +}; + +const setup = (props?: Partial) => + render(); + +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 = ( + <> + + + + ); + + setup({ defaultPrimaryActions: customPrimaryActions }); + + expect( + screen.getByRole('button', { name: 'Custom Action 1' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Custom Action 2' }), + ).toBeInTheDocument(); +}); + +test('renders with empty secondary actions', () => { + setup({ defaultSecondaryActions: [] }); + + const menuExtensions = screen.getAllByTestId('mock-menu-extension'); + const secondaryExtension = menuExtensions[1]; + + expect(secondaryExtension).toHaveAttribute('data-default-items-count', '0'); +}); + +test('passes correct viewId (ViewContribution.Editor) to MenuListExtension', () => { + setup(); + const menuExtensions = screen.getAllByTestId('mock-menu-extension'); + + menuExtensions.forEach(extension => { + expect(extension).toHaveAttribute('data-view-id', 'sqllab.editor'); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx new file mode 100644 index 00000000000..f8b7ead860b --- /dev/null +++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Divider, Flex } from '@superset-ui/core/components'; +import { styled } from '@apache-superset/core/ui'; +import { ViewContribution } from 'src/SqlLab/contributions'; +import MenuListExtension, { + type MenuListExtensionProps, +} from 'src/components/MenuListExtension'; + +const StyledFlex = styled(Flex)` + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; + + & .ant-divider { + margin: ${({ theme }) => theme.sizeUnit * 2}px 0; + height: ${({ theme }) => theme.sizeUnit * 6}px; + } +`; +export interface SqlEditorTopBarProps { + queryEditorId: string; + defaultPrimaryActions: React.ReactNode; + defaultSecondaryActions: MenuListExtensionProps['defaultItems']; +} + +const SqlEditorTopBar = ({ + defaultPrimaryActions, + defaultSecondaryActions, +}: SqlEditorTopBarProps) => ( + + + + + {defaultPrimaryActions} + + + + + + + +); + +export default SqlEditorTopBar; diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts new file mode 100644 index 00000000000..820f820be30 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.test.ts @@ -0,0 +1,320 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { createWrapper } from 'spec/helpers/testing-library'; +import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures'; +import * as localStorageHelpers from 'src/utils/localStorageHelpers'; + +import useDatabaseSelector from './useDatabaseSelector'; + +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); + +const mockDatabase = { + id: 1, + database_name: 'main', + backend: 'mysql', +}; + +const mockDatabases = { + [mockDatabase.id]: mockDatabase, +}; + +const createInitialState = (overrides = {}) => ({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + databases: mockDatabases, + ...overrides, + }, +}); + +beforeEach(() => { + jest.spyOn(localStorageHelpers, 'getItem').mockReturnValue(null); + jest.spyOn(localStorageHelpers, 'setItem').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +test('returns initial values from query editor', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + expect(result.current.catalog).toBe(defaultQueryEditor.catalog); + expect(result.current.schema).toBe(defaultQueryEditor.schema); + expect(typeof result.current.onDbChange).toBe('function'); + expect(typeof result.current.onCatalogChange).toBe('function'); + expect(typeof result.current.onSchemaChange).toBe('function'); + expect(typeof result.current.getDbList).toBe('function'); + expect(typeof result.current.handleError).toBe('function'); +}); + +test('returns database when dbId exists in store', () => { + const store = mockStore( + createInitialState({ + unsavedQueryEditor: { + id: defaultQueryEditor.id, + dbId: mockDatabase.id, + }, + }), + ); + + const { result, rerender } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + // Trigger effect by rerendering + rerender(); + + expect(result.current.db).toEqual(mockDatabase); +}); + +test('dispatches QUERY_EDITOR_SETDB action on onDbChange', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + act(() => { + result.current.onDbChange({ id: 2 }); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'QUERY_EDITOR_SETDB', + dbId: 2, + }), + ); +}); + +test('dispatches queryEditorSetCatalog action on onCatalogChange', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + act(() => { + result.current.onCatalogChange('new_catalog'); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'QUERY_EDITOR_SET_CATALOG', + }), + ); +}); + +test('dispatches queryEditorSetSchema action on onSchemaChange', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + act(() => { + result.current.onSchemaChange('new_schema'); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'QUERY_EDITOR_SET_SCHEMA', + }), + ); +}); + +test('dispatches setDatabases action on getDbList', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + const newDatabase = { + id: 3, + database_name: 'test_db', + backend: 'postgresql', + }; + + act(() => { + result.current.getDbList(newDatabase as any); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'SET_DATABASES', + }), + ); +}); + +test('dispatches addDangerToast action on handleError', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + act(() => { + result.current.handleError('Test error message'); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'ADD_TOAST', + payload: expect.objectContaining({ + toastType: 'DANGER_TOAST', + text: 'Test error message', + }), + }), + ); +}); + +test('reads database from localStorage when URL has db param', () => { + const localStorageDb = { + id: 5, + database_name: 'local_storage_db', + backend: 'sqlite', + }; + + jest.spyOn(localStorageHelpers, 'getItem').mockReturnValue(localStorageDb); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { search: '?db=true' }, + writable: true, + }); + + const store = mockStore(createInitialState()); + const { result, rerender } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + rerender(); + + expect(result.current.db).toEqual(localStorageDb); + expect(localStorageHelpers.setItem).toHaveBeenCalledWith( + localStorageHelpers.LocalStorageKeys.Database, + null, + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); +}); + +test('returns null db when dbId does not exist in databases', () => { + const store = mockStore( + createInitialState({ + databases: {}, + }), + ); + + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + expect(result.current.db).toBeNull(); +}); + +test('handles null catalog change', () => { + const store = mockStore(createInitialState()); + const { result } = renderHook( + () => useDatabaseSelector(defaultQueryEditor.id), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + + act(() => { + result.current.onCatalogChange(null); + }); + + const actions = store.getActions(); + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'QUERY_EDITOR_SET_CATALOG', + }), + ); +}); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts new file mode 100644 index 00000000000..984f7b2a89b --- /dev/null +++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/useDatabaseSelector.ts @@ -0,0 +1,126 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { SqlLabRootState } from 'src/SqlLab/types'; +import { + queryEditorSetDb, + queryEditorSetCatalog, + queryEditorSetSchema, + setDatabases, + addDangerToast, + type Database, +} from 'src/SqlLab/actions/sqlLab'; +import { type DatabaseObject } from 'src/components'; +import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; +import { + getItem, + LocalStorageKeys, + setItem, +} from 'src/utils/localStorageHelpers'; + +export default function useDatabaseSelector(queryEditorId: string) { + const databases = useSelector< + SqlLabRootState, + SqlLabRootState['sqlLab']['databases'] + >(({ sqlLab }) => sqlLab.databases); + const dispatch = useDispatch(); + const queryEditor = useQueryEditor(queryEditorId, [ + 'dbId', + 'catalog', + 'schema', + 'tabViewId', + ]); + const database = useMemo( + () => (queryEditor.dbId ? databases[queryEditor.dbId] : undefined), + [databases, queryEditor.dbId], + ); + const [userSelectedDb, setUserSelected] = useState( + null, + ); + const { catalog, schema } = queryEditor; + + const onDbChange = useCallback( + ({ id: dbId }: { id: number }) => { + if (queryEditor) { + dispatch(queryEditorSetDb(queryEditor, dbId)); + } + }, + [dispatch, queryEditor], + ); + + const handleCatalogChange = useCallback( + (catalog: string | null) => { + if (queryEditor) { + dispatch(queryEditorSetCatalog(queryEditor, catalog)); + } + }, + [dispatch, queryEditor], + ); + + const handleSchemaChange = useCallback( + (schema: string) => { + if (queryEditor) { + dispatch(queryEditorSetSchema(queryEditor, schema)); + } + }, + [dispatch, queryEditor], + ); + + const handleDbList = useCallback( + (result: DatabaseObject[]) => { + dispatch(setDatabases(result as unknown as Database[])); + }, + [dispatch], + ); + + const handleError = useCallback( + (message: string) => { + dispatch(addDangerToast(message)); + }, + [dispatch], + ); + + useEffect(() => { + const bool = new URLSearchParams(window.location.search).get('db'); + const userSelected = getItem( + LocalStorageKeys.Database, + null, + ) as DatabaseObject | null; + + if (bool && userSelected) { + setUserSelected(userSelected); + setItem(LocalStorageKeys.Database, null); + } else if (database) { + setUserSelected(database); + } + }, [database]); + + return { + db: userSelectedDb, + catalog, + schema, + getDbList: handleDbList, + handleError, + onDbChange, + onCatalogChange: handleCatalogChange, + onSchemaChange: handleSchemaChange, + }; +} diff --git a/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx new file mode 100644 index 00000000000..3cf0ea3fb31 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx @@ -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 }) => ( +
+ ViewListExtension +
+ ), +})); + +test('renders StatusBar component', () => { + render(); + expect(screen.getByTestId('mock-view-extension')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/SqlLab/components/StatusBar/index.tsx b/superset-frontend/src/SqlLab/components/StatusBar/index.tsx new file mode 100644 index 00000000000..97ce1e7fbd0 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/StatusBar/index.tsx @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { styled } from '@apache-superset/core'; +import { Flex } from '@superset-ui/core/components'; +import ViewListExtension from 'src/components/ViewListExtension'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; +import { SQL_EDITOR_STATUSBAR_HEIGHT } from 'src/SqlLab/constants'; +import { ViewContribution } from 'src/SqlLab/contributions'; + +const Container = styled(Flex)` + flex-direction: row-reverse; + height: ${SQL_EDITOR_STATUSBAR_HEIGHT}px; + background-color: ${({ theme }) => theme.colorPrimary}; + color: ${({ theme }) => theme.colorWhite}; + padding: 0 ${({ theme }) => theme.sizeUnit * 4}px; + + & .ant-tag { + color: ${({ theme }) => theme.colorWhite}; + background-color: transparent; + border: 0; + } +`; + +const StatusBar = () => { + const statusBarContributions = + ExtensionsManager.getInstance().getViewContributions( + ViewContribution.StatusBar, + ) || []; + + return ( + <> + {statusBarContributions.length > 0 && ( + + + + )} + + ); +}; + +export default StatusBar; diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 9a78a116bb0..83c870e1233 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -42,6 +42,42 @@ const StyledEditableTabs = styled(EditableTabs)` height: 100%; display: flex; flex-direction: column; + & .ant-tabs-nav::before { + border-color: ${({ theme }) => theme.colorBorder} !important; + } + & .ant-tabs-nav-add { + border-color: ${({ theme }) => theme.colorBorder} !important; + height: 34px; + } + & .ant-tabs-nav-list { + align-items: end; + padding-top: 1px; + column-gap: ${({ theme }) => theme.sizeUnit}px; + } + & .ant-tabs-tab-active { + border-left-color: ${({ theme }) => theme.colorPrimaryActive} !important; + border-top-color: ${({ theme }) => theme.colorPrimaryActive} !important; + border-right-color: ${({ theme }) => theme.colorPrimaryActive} !important; + box-shadow: 0 0 2px ${({ theme }) => theme.colorPrimaryActive} !important; + border-top: 2px; + } + & .ant-tabs-tab { + border-radius: 2px 2px 0px 0px !important; + padding: ${({ theme }) => theme.sizeUnit}px + ${({ theme }) => theme.sizeUnit * 2}px !important; + & + .ant-tabs-nav-add { + margin-right: ${({ theme }) => theme.sizeUnit * 4}px; + } + &:not(.ant-tabs-tab-active) { + border-color: ${({ theme }) => theme.colorBorder} !important; + box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder} !important; + } + } + & .ant-tabs-nav-add { + border-radius: 2px 2px 0px 0px !important; + min-height: auto !important; + align-self: flex-end; + } `; const StyledTab = styled.span` @@ -198,14 +234,14 @@ class TabbedSqlEditors extends PureComponent { addIcon={ - ({ + commands: { + executeCommand: jest.fn(), + }, +})); + +function createMockCommand( + command: string, + overrides: Partial = {}, +): 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 { + return { + context: [], + primary: [], + secondary: [], + ...overrides, + }; +} + +function createMockExtension( + options: Partial & { + commands?: contributions.CommandContribution[]; + menus?: Record; + } = {}, +): 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[0] = {}, +): Promise { + 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( + + + , + ); + + 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(); + + 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( + + + , + ); + + 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(); + + 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(); + + 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( + , + ); + + expect(container).toBeEmptyDOMElement(); +}); + +test('renders dropdown button when secondary mode with defaultItems', () => { + render( + , + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); +}); + +test('renders dropdown menu with defaultItems when clicked', async () => { + render( + , + ); + + 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(); + + 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( + , + ); + + 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(); + + 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(); + + expect(await screen.findByText('test.action1 Title')).toBeInTheDocument(); + expect(screen.getByText('test.action2 Title')).toBeInTheDocument(); +}); + +test('handles viewId with no matching contributions', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Fallback' })).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/MenuListExtension/index.tsx b/superset-frontend/src/components/MenuListExtension/index.tsx new file mode 100644 index 00000000000..98608f6a291 --- /dev/null +++ b/superset-frontend/src/components/MenuListExtension/index.tsx @@ -0,0 +1,157 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useMemo } from 'react'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { Button, Dropdown } from '@superset-ui/core/components'; +import { Menu, MenuItemType } from '@superset-ui/core/components/Menu'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { commands } from 'src/core'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; + +export type MenuListExtensionProps = { + viewId: string; +} & ( + | { + primary: boolean; + secondary?: never; + children?: React.ReactNode; + defaultItems?: never; + compactMode?: boolean; + } + | { + primary?: never; + secondary: boolean; + children?: never; + defaultItems?: MenuItemType[]; + compactMode?: never; + } +); + +const MenuListExtension = ({ + viewId, + primary, + secondary, + defaultItems, + children, + compactMode, +}: MenuListExtensionProps) => { + const theme = useTheme(); + const contributions = + ExtensionsManager.getInstance().getMenuContributions(viewId); + + const actions = primary ? contributions?.primary : contributions?.secondary; + const primaryActions = useMemo( + () => + primary + ? (actions || []).map(contribution => { + const command = + ExtensionsManager.getInstance().getCommandContribution( + contribution.command, + )!; + if (!command?.icon) { + return null; + } + const Icon = + (Icons as Record)[ + command.icon + ] ?? Icons.FileOutlined; + + return ( + + ); + }) + : [], + [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 ( + ( + div { + gap: ${theme.sizeUnit * 4}px; + } + `} + items={secondaryActions} + /> + )} + trigger={['click']} + > + + + ); + } + return ( + <> + {primaryActions} + {children} + + ); +}; + +export default MenuListExtension; diff --git a/superset-frontend/src/components/ViewListExtension/ViewListExtension.test.tsx b/superset-frontend/src/components/ViewListExtension/ViewListExtension.test.tsx new file mode 100644 index 00000000000..906c1d91512 --- /dev/null +++ b/superset-frontend/src/components/ViewListExtension/ViewListExtension.test.tsx @@ -0,0 +1,198 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ReactElement } from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import type { contributions, core } from '@apache-superset/core'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; +import { ExtensionsProvider } from 'src/extensions/ExtensionsContext'; +import ViewListExtension from '.'; + +function createMockView( + id: string, + overrides: Partial = {}, +): contributions.ViewContribution { + return { + id, + name: `${id} View`, + ...overrides, + }; +} + +function createMockExtension( + options: Partial & { + views?: Record; + } = {}, +): 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[0] = {}, +): Promise { + 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( + , + ); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + expect(screen.getByText(/view-a-component/)).toBeInTheDocument(); + expect(screen.queryByText(/view-b-component/)).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(/view-b-component/)).toBeInTheDocument(); + expect(screen.queryByText(/view-a-component/)).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/ViewListExtension/index.tsx b/superset-frontend/src/components/ViewListExtension/index.tsx new file mode 100644 index 00000000000..a7f52093567 --- /dev/null +++ b/superset-frontend/src/components/ViewListExtension/index.tsx @@ -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;