diff --git a/superset-frontend/packages/superset-core/src/sqlLab/index.ts b/superset-frontend/packages/superset-core/src/sqlLab/index.ts index 844c25f9ea63..cf7f4f076cf7 100644 --- a/superset-frontend/packages/superset-core/src/sqlLab/index.ts +++ b/superset-frontend/packages/superset-core/src/sqlLab/index.ts @@ -62,6 +62,14 @@ export interface Tab { */ id: string; + /** + * The stable backend-assigned identifier for this tab. Exposed as an opaque + * string so the public extension API does not leak the backend's internal + * numeric tab id. Set once the tab has been persisted to the backend; + * undefined for new tabs before the first backend sync. + */ + backendId?: string; + /** * The display title of the tab. * This is what users see in the tab header. diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx index 86c4a6b23da8..7e8e8ef9ac87 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx @@ -41,6 +41,8 @@ import { import ResultSet from 'src/SqlLab/components/ResultSet'; import { api } from 'src/hooks/apiResources/queryApi'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; +import { views } from 'src/core'; +import { ViewLocations } from 'src/SqlLab/contributions'; import type { Action, Middleware, Store } from 'redux'; import SqlEditor, { Props } from '.'; @@ -348,6 +350,29 @@ describe('SqlEditor', () => { ).toBeInTheDocument(); }); + test('renders a registered northPane view in place of the editor', async () => { + const { queryEditor } = mockedProps; + // The fixture has no tabViewId, so the component falls back to the id; + // mirror that here to derive the same persistence key. + const storageKey = `sqllab.northPaneView.${queryEditor.id}`; + localStorage.setItem(storageKey, 'test.northPane'); + const disposable = views.registerView( + { id: 'test.northPane', name: 'Test North Pane' }, + ViewLocations.sqllab.northPane, + () =>
NorthPane content
, + ); + + try { + const { findByTestId, queryByTestId } = setup(mockedProps, store); + expect(await findByTestId('np-view')).toBeInTheDocument(); + // The default SQL editor pane is replaced, not rendered alongside. + expect(queryByTestId('react-ace')).not.toBeInTheDocument(); + } finally { + disposable.dispose(); + localStorage.removeItem(storageKey); + } + }); + // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('with EstimateQueryCost enabled', () => { beforeEach(() => { diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index c07c01223f5f..d502ea192b3a 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -121,6 +121,37 @@ import KeyboardShortcutButton, { KeyboardShortcut, } from '../KeyboardShortcutButton'; import SqlEditorTopBar from '../SqlEditorTopBar'; +import { + ViewLocations, + PENDING_NORTH_PANE_VIEW_KEY, +} from 'src/SqlLab/contributions'; +import { resolveView, useViews } from 'src/core/views'; + +/** Per-tab localStorage key storing the active northPane view ID. */ +const NORTH_PANE_VIEW_KEY = (tabId: string) => `sqllab.northPaneView.${tabId}`; + +// The northPane keys are dynamic per-tab strings rather than members of the +// typed LocalStorageKeys enum, so the typed helpers don't apply. Guard the raw +// access here so a storage-restricted browser can't crash the editor mount. +const readNorthPaneStorage = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +const writeNorthPaneStorage = (key: string, value: string | null): void => { + try { + if (value === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, value); + } + } catch { + // localStorage may be unavailable (blocked/quota/private mode); ignore. + } +}; const bootstrapData = getBootstrapData(); const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES; @@ -271,6 +302,48 @@ const SqlEditor: FC = ({ const logAction = useLogAction({ queryEditorId: queryEditor.id }); const isActive = currentQueryEditorId === queryEditor.id; + + // Re-renders when an extension registers a northPane view after async load. + const northPaneViews = useViews(ViewLocations.sqllab.northPane) || []; + + // Resolve the per-tab localStorage key the same way every other SQL Lab + // consumer does (`tabViewId ?? id`), so the value written, read back, and + // observed via the `storage` event all agree once a tab is backend-persisted. + const northPaneStorageId = queryEditor.tabViewId ?? queryEditor.id; + + // ID of the northPane view active for this tab, or null for the default + // SQL editor layout. Set by an extension via PENDING_NORTH_PANE_VIEW_KEY + // before calling createTab(); persisted per-tab in localStorage. + const [northPaneViewId, setNorthPaneViewId] = useState(() => { + const pendingViewId = readNorthPaneStorage(PENDING_NORTH_PANE_VIEW_KEY); + if (pendingViewId) { + writeNorthPaneStorage(PENDING_NORTH_PANE_VIEW_KEY, null); + writeNorthPaneStorage( + NORTH_PANE_VIEW_KEY(northPaneStorageId), + pendingViewId, + ); + return pendingViewId; + } + return readNorthPaneStorage(NORTH_PANE_VIEW_KEY(northPaneStorageId)); + }); + + useEffect(() => { + writeNorthPaneStorage( + NORTH_PANE_VIEW_KEY(northPaneStorageId), + northPaneViewId, + ); + }, [northPaneStorageId, northPaneViewId]); + + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key === NORTH_PANE_VIEW_KEY(northPaneStorageId)) { + setNorthPaneViewId(e.newValue || null); + } + }; + window.addEventListener('storage', handler); + return () => window.removeEventListener('storage', handler); + }, [northPaneStorageId]); + const [autorun, setAutorun] = useState(queryEditor.autorun); const [ctas, setCtas] = useState(''); const [northPercent, setNorthPercent] = useState( @@ -1046,6 +1119,30 @@ const SqlEditor: FC = ({ 'Choose one of the available databases from the panel on the left.', )} /> + ) : northPaneViewId && + northPaneViews.some(v => v.id === northPaneViewId) ? ( +
+ +
+ {resolveView(northPaneViewId)} +
+
) : ( queryPane() )} diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 445704c1bc1c..11b89ba00753 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { PureComponent, useState, useMemo } from 'react'; import { EditableTabs } from '@superset-ui/core/components/Tabs'; import { connect } from 'react-redux'; import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; @@ -24,11 +24,14 @@ import { t } from '@apache-superset/core/translation'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; import { Logger } from 'src/logger/LogUtils'; -import { EmptyState, Tooltip } from '@superset-ui/core/components'; +import { Dropdown, EmptyState, Tooltip } from '@superset-ui/core/components'; +import { MenuItemType } from '@superset-ui/core/components/Menu'; import { ErrorBoundary } from 'src/components/ErrorBoundary'; import { detectOS } from 'src/utils/common'; import * as Actions from 'src/SqlLab/actions/sqlLab'; import { Icons } from '@superset-ui/core/components/Icons'; +import { menus, commands } from 'src/core'; +import { ViewLocations } from 'src/SqlLab/contributions'; import SqlEditor from '../SqlEditor'; import SqlEditorTabHeader from '../SqlEditorTabHeader'; @@ -98,6 +101,101 @@ const AddTabIconWrapper = styled.span` // Get the user's OS const userOS = detectOS(); +const newTabTooltip = + userOS === 'Windows' ? t('New tab (Ctrl + q)') : t('New tab (Ctrl + t)'); + +const PlusIcon = ( + + + +); + +function NewTabButton({ onAddSqlEditor }: { onAddSqlEditor: () => void }) { + const [open, setOpen] = useState(false); + + const dropdownItems = useMemo(() => { + if (!open) return []; + const primaryItems = + menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? []; + return [ + { + key: 'sql-editor', + label: t('SQL Editor'), + icon: , + onClick: () => { + setOpen(false); + onAddSqlEditor(); + }, + }, + ...primaryItems.map(item => { + const command = commands.getCommand(item.command); + const Icon = command?.icon + ? ((Icons as Record)[ + command.icon + ] ?? Icons.FileOutlined) + : Icons.FileOutlined; + return { + key: command?.id ?? item.command, + label: command?.title ?? item.command, + icon: , + onClick: () => { + setOpen(false); + commands.executeCommand(item.command); + }, + } as MenuItemType; + }), + ]; + }, [open, onAddSqlEditor]); + + const activate = () => { + const primaryItems = + menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? []; + if (primaryItems.length === 0) { + onAddSqlEditor(); + } else { + setOpen(prev => !prev); + } + }; + + const handleClick = (e: React.MouseEvent) => { + // Antd's Tabs wraps addIcon in its own