Compare commits

...

14 Commits

Author SHA1 Message Date
Enzo Martellucci
4ee68d89f3 Merge branch 'master' into feat/sqllab-contribution-points-clean 2026-07-03 10:30:54 +02:00
Evan
5a4afe78c7 test(sqllab): cover PENDING_NORTH_PANE_VIEW_KEY consume-and-persist flow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:31:21 -07:00
Evan
29d8f9da8a fix(sqllab): centralize add-tab timing marker and use antd Flex for northPane wrapper
Fold Logger.markTimeOrigin() into newQueryEditor so every add-tab entry
point (the + button, its dropdown, and antd's onEdit) records add-tab
performance telemetry consistently, and swap the northPane view wrapper
from a custom flex div to the antd Flex component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:19:06 -07:00
Enzo Martellucci
8b86bbc95d test(sqllab): cover NewTabButton dropdown, direct-add, and skip paths
The new-tab "+" button (NewTabButton) had no test for its contributed
behavior: the dropdown listing "SQL Editor" plus extension items, the
empty-contributions branch that adds a tab directly, and the guard that
skips a contributed menu item whose command isn't registered.

Add regression tests that register a sqllab.newTab command/menu item and
assert each branch, mirroring how SqlEditor.test.tsx registers a view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:13:02 -07:00
Evan
27f05d7639 fix(sqllab): intercept new-tab activation on antd's add button for keyboard parity
Move the new-tab interception from the inner span to a capture-phase click
listener on antd's add button, which is the element that actually receives
focus and keyboard activation. A native button synthesizes a click for both
mouse and Enter/Space, so one capture listener keeps keyboard and mouse
behavior in sync and drops the duplicate focusable span tab stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:13:01 -07:00
Evan
e3b4ac215a fix(sqllab): skip new-tab menu items whose command isn't registered
Guards against executeCommand throwing "Command not found" when an
extension contributes a menu item before its command finishes loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:13:01 -07:00
Evan
a17472bbb1 test(sqllab,extensions): bridge store state cast through unknown for tsc
The concrete store-state cast tripped TS2352 because getState()'s static
type doesn't overlap the narrowed shape. Bridge through unknown, which
tsc recommends and keeps the cast off any.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:13:00 -07:00
Evan
1181df7fd6 test(sqllab,extensions): replace any casts with concrete types
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:59 -07:00
Evan
276cae3ba7 test(sqllab): cover Tab backendId mapping from editor tabViewId
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:59 -07:00
Evan
d817a3be77 fix(sqllab,extensions): populate Tab.backendId and guard northPane storage
Wire the backend-assigned tabViewId through to the public Tab.backendId
field so extensions can correlate tabs with tabstateview rows, and wrap
the northPane localStorage access in try/catch so a storage-restricted
browser can't crash the editor mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:58 -07:00
Evan
7af82a9041 test(sqllab,extensions): assert toast via store state; fix northPane key type
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:57 -07:00
Claude Code
0378c85ed9 test(sqllab): cover northPane view rendering in SqlEditor
Addresses the review note about missing coverage for the northPane
contribution surface: registers a view at sqllab.northPane, sets the
per-tab localStorage key, and asserts SqlEditor renders the resolved
view in place of the default editor pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:12:56 -07:00
Evan
0c7b5d34f2 fix(sqllab,extensions): address review feedback on northPane keys, tab a11y, extension load errors
- Resolve the northPane localStorage key consistently as `tabViewId ?? id`
  for read, write, and the storage listener so backend-persisted tabs restore
  and cross-tab sync correctly.
- Add keyboard activation to the SQL Lab new-tab button so extension-contributed
  tab types are reachable via Enter/Space.
- Make ExtensionsLoader rethrow on failure so ExtensionsStartup surfaces a
  warning toast instead of swallowing the error; reset the promise to allow retry.
- Clarify the public Tab.backendId docstring (opaque string, not an internal
  numeric id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:55 -07:00
Amin Ghadersohi
72e2340715 feat(sqllab,extensions): contribution surfaces for tab/pane extensions
Lets extensions contribute first-class SQL Lab experiences — replacing
the default editor split with their own pane, and adding their own tab
types to the new-tab dropdown.

Changes:
- Add two view locations to SqlLab/contributions.ts:
  - sqllab.northPane — full-pane replacement for the default editor+SouthPane split
  - sqllab.newTab — tab types listed in the '+' new-tab dropdown
- Expose PENDING_NORTH_PANE_VIEW_KEY: extensions set this localStorage key
  before calling sqlLab.createTab() to declare which northPane view the new
  tab opens with. SqlEditor consumes/removes the key on init, then persists
  the choice per-tab so the mode survives reloads.
- Expose Tab.backendId on the public superset-core Tab interface so
  extensions can correlate UI tabs with their tabstateview row.
- TabbedSqlEditors: the '+' button becomes a Dropdown when extensions
  contribute newTab items, listing 'SQL Editor' (built-in) plus contributed
  tab types.
- ExtensionsStartup: surface extension load errors as warning toasts
  instead of only logging.

Rebased onto current apache/master (dropping the unmerged storage-tiers
stack this was originally branched on). Adapted to master's evolution:
SqlEditor now consumes master's reactive useViews() hook (#40915) instead
of a custom onViewsChange() subscription, so the northPane view appears
when an extension registers it asynchronously — without blocking initial
render. ExtensionsStartup keeps master's non-blocking async load and adds
the error-toast surfacing.

Co-Authored-By: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:12:18 -07:00
13 changed files with 483 additions and 39 deletions

View File

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

View File

@@ -41,6 +41,11 @@ 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,
PENDING_NORTH_PANE_VIEW_KEY,
} from 'src/SqlLab/contributions';
import type { Action, Middleware, Store } from 'redux';
import SqlEditor, { Props } from '.';
@@ -348,6 +353,55 @@ 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,
() => <div data-test="np-view">NorthPane content</div>,
);
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);
}
});
test('consumes PENDING_NORTH_PANE_VIEW_KEY, clearing it and persisting the per-tab key', async () => {
const { queryEditor } = mockedProps;
// The fixture has no tabViewId, so the component falls back to the id.
const storageKey = `sqllab.northPaneView.${queryEditor.id}`;
// An extension declares the pending northPane view before createTab().
localStorage.setItem(PENDING_NORTH_PANE_VIEW_KEY, 'test.northPane');
const disposable = views.registerView(
{ id: 'test.northPane', name: 'Test North Pane' },
ViewLocations.sqllab.northPane,
() => <div data-test="np-view">NorthPane content</div>,
);
try {
const { findByTestId } = setup(mockedProps, store);
expect(await findByTestId('np-view')).toBeInTheDocument();
// The pending key is consumed (removed) on mount...
expect(localStorage.getItem(PENDING_NORTH_PANE_VIEW_KEY)).toBeNull();
// ...and the chosen view is persisted under the per-tab key.
expect(localStorage.getItem(storageKey)).toEqual('test.northPane');
} finally {
disposable.dispose();
localStorage.removeItem(storageKey);
localStorage.removeItem(PENDING_NORTH_PANE_VIEW_KEY);
}
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('with EstimateQueryCost enabled', () => {
beforeEach(() => {

View File

@@ -55,6 +55,7 @@ import {
Button,
Divider,
EmptyState,
Flex,
Input,
Modal,
} from '@superset-ui/core/components';
@@ -121,6 +122,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 +303,48 @@ const SqlEditor: FC<Props> = ({
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<string | null>(() => {
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 +1120,29 @@ const SqlEditor: FC<Props> = ({
'Choose one of the available databases from the panel on the left.',
)}
/>
) : northPaneViewId &&
northPaneViews.some(v => v.id === northPaneViewId) ? (
<Flex
vertical
css={css`
height: 100%;
`}
>
<SqlEditorTopBar
queryEditorId={queryEditor.id}
defaultPrimaryActions={null}
defaultSecondaryActions={[]}
/>
<div
css={css`
flex: 1;
overflow: auto;
padding: 0 ${theme.sizeUnit * 4}px;
`}
>
{resolveView(northPaneViewId)}
</div>
</Flex>
) : (
queryPane()
)}

View File

@@ -29,6 +29,8 @@ import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
import { Store } from 'redux';
import { RootState } from 'src/views/store';
import { QueryEditor } from 'src/SqlLab/types';
import { commands, menus } from 'src/core';
import { ViewLocations } from 'src/SqlLab/contributions';
jest.mock('src/SqlLab/components/SqlEditor', () =>
// eslint-disable-next-line react/display-name
@@ -172,3 +174,94 @@ test('should have an empty state when query editors is empty', async () => {
expect(getByText('Add a new tab to create SQL Query')).toBeInTheDocument(),
);
});
// The new-tab "+" button (NewTabButton) opens a dropdown of contributed
// actions when an extension registers something under sqllab.newTab, and
// otherwise falls back to adding a SQL editor tab directly. These tests cover
// that branching plus the resilience to a contributed-but-unregistered command.
const newTabDisposables: ReturnType<typeof menus.registerMenuItem>[] = [];
afterEach(() => {
while (newTabDisposables.length) {
newTabDisposables.pop()?.dispose();
}
});
const contributeNewTabItem = (command: string) =>
newTabDisposables.push(
menus.registerMenuItem(
{ view: 'builtin.editor', command },
ViewLocations.sqllab.newTab,
'primary',
),
);
test('new tab button opens a dropdown listing SQL Editor and the contributed item', async () => {
contributeNewTabItem('ext.newTab');
newTabDisposables.push(
commands.registerCommand(
{ id: 'ext.newTab', title: 'Contributed Tab' },
jest.fn(),
),
);
setup(undefined, initialState);
fireEvent.click(screen.getAllByLabelText('Add tab')[0]);
expect(await screen.findByText('SQL Editor')).toBeInTheDocument();
expect(screen.getByText('Contributed Tab')).toBeInTheDocument();
});
test('new tab button runs the contributed command when its menu item is clicked', async () => {
const handler = jest.fn();
contributeNewTabItem('ext.newTab');
newTabDisposables.push(
commands.registerCommand(
{ id: 'ext.newTab', title: 'Contributed Tab' },
handler,
),
);
setup(undefined, initialState);
fireEvent.click(screen.getAllByLabelText('Add tab')[0]);
fireEvent.click(await screen.findByText('Contributed Tab'));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
});
test('new tab button adds a tab directly when there are no contributions', async () => {
const { getAllByLabelText, getAllByRole, queryByText } = setup(
undefined,
initialState,
);
const tabCount = getAllByRole('tab').filter(
tab => !tab.classList.contains('ant-tabs-tab-remove'),
).length;
fireEvent.click(getAllByLabelText('Add tab')[0]);
// No dropdown appears; a new editor tab is created immediately.
expect(queryByText('SQL Editor')).not.toBeInTheDocument();
await waitFor(() =>
expect(
getAllByRole('tab').filter(
tab => !tab.classList.contains('ant-tabs-tab-remove'),
).length,
).toEqual(tabCount + 1),
);
});
test('new tab button skips a contributed item whose command is not registered', async () => {
// Menu item registered, but its command never is — the item must be dropped
// rather than throwing "Command not found" when the dropdown renders.
contributeNewTabItem('ext.missing');
setup(undefined, initialState);
fireEvent.click(screen.getAllByLabelText('Add tab')[0]);
expect(await screen.findByText('SQL Editor')).toBeInTheDocument();
expect(screen.queryByText('ext.missing')).not.toBeInTheDocument();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import { EditableTabs } from '@superset-ui/core/components/Tabs';
import { connect } from 'react-redux';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -24,12 +24,15 @@ import { t } from '@apache-superset/core/translation';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { styled, css } 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 { SQLLAB_TAB_OVERFLOW_POPUP_CLASS } from 'src/SqlLab/SqlLabGlobalStyles';
import { menus, commands } from 'src/core';
import { ViewLocations } from 'src/SqlLab/contributions';
import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
@@ -94,6 +97,114 @@ const TabTitle = 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 = (
<Icons.PlusOutlined
iconSize="l"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
);
function NewTabButton({ onAddSqlEditor }: { onAddSqlEditor: () => void }) {
const [open, setOpen] = useState(false);
const dropdownItems = useMemo<MenuItemType[]>(() => {
if (!open) return [];
const primaryItems =
menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
return [
{
key: 'sql-editor',
label: t('SQL Editor'),
icon: <Icons.TableOutlined iconSize="m" />,
onClick: () => {
setOpen(false);
onAddSqlEditor();
},
},
...primaryItems.flatMap(item => {
const command = commands.getCommand(item.command);
if (!command) {
// An extension contributed this menu item but its command isn't
// registered (load is still pending or failed). Skip it so clicking
// can't throw "Command not found" and break the add-tab flow.
return [];
}
const Icon = command.icon
? ((Icons as Record<string, typeof Icons.FileOutlined>)[
command.icon
] ?? Icons.FileOutlined)
: Icons.FileOutlined;
return [
{
key: command.id,
label: command.title ?? item.command,
icon: <Icon iconSize="m" />,
onClick: () => {
setOpen(false);
commands.executeCommand(item.command);
},
} as MenuItemType,
];
}),
];
}, [open, onAddSqlEditor]);
const activate = useCallback(() => {
const primaryItems =
menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
if (primaryItems.length === 0) {
onAddSqlEditor();
} else {
setOpen(prev => !prev);
}
}, [onAddSqlEditor]);
const anchorRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
// Antd's Tabs wraps addIcon in its own <button onClick={() => onEdit('add')}>,
// and that button is the element that actually receives focus and activation.
// Intercept on the button itself in the capture phase so the extension
// dropdown is reached before antd's default add-tab path runs. A native button
// synthesizes a click for both mouse and keyboard (Enter/Space) activation, so
// a single capture-phase click listener keeps keyboard and mouse behavior in
// sync — a handler on the inner span only fires when the span is the event
// target and is bypassed when the button is activated via the keyboard.
const button = anchorRef.current?.closest('button');
if (!button) {
return undefined;
}
const handleActivate = (e: Event) => {
e.preventDefault();
e.stopPropagation();
activate();
};
button.addEventListener('click', handleActivate, true);
return () => {
button.removeEventListener('click', handleActivate, true);
};
}, [activate]);
return (
<Tooltip id="add-tab" placement="left" title={newTabTooltip}>
<Dropdown
open={open}
onOpenChange={setOpen}
menu={{ items: dropdownItems }}
trigger={[]}
>
<span ref={anchorRef}>{PlusIcon}</span>
</Dropdown>
</Tooltip>
);
}
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
function TabbedSqlEditors({
@@ -140,6 +251,10 @@ function TabbedSqlEditors({
}, [queries, activeQueryEditor, actions, displayLimit]);
const newQueryEditor = useCallback(() => {
// Mark the timing origin for add-tab performance telemetry. Centralized here
// so every add-tab entry point (the "+" button, its dropdown, and antd's
// onEdit) records it consistently.
Logger.markTimeOrigin();
actions.addNewQueryEditor();
}, [actions]);
@@ -173,7 +288,6 @@ function TabbedSqlEditors({
}
}
if (action === 'add') {
Logger.markTimeOrigin();
newQueryEditor();
}
},
@@ -265,25 +379,7 @@ function TabbedSqlEditors({
onEdit={handleEdit}
popupClassName={SQLLAB_TAB_OVERFLOW_POPUP_CLASS}
type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
addIcon={
<Tooltip
id="add-tab"
placement="left"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
<Icons.PlusOutlined
iconSize="l"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
</Tooltip>
}
addIcon={<NewTabButton onAddSqlEditor={() => newQueryEditor()} />}
items={tabItems}
/>
);

View File

@@ -46,5 +46,27 @@ export const ViewLocations = {
statusBar: 'sqllab.statusBar',
results: 'sqllab.results',
queryHistory: 'sqllab.queryHistory',
// Extensions can register a full-pane replacement here. SqlEditor renders
// the registered view instead of the default editor+SouthPane split when
// a tab was opened in that mode.
northPane: 'sqllab.northPane',
// Extensions register tab-type commands here. When any are present the
// "+" new-tab button becomes a dropdown listing all registered tab types
// plus the built-in SQL Editor option.
newTab: 'sqllab.newTab',
},
} as const;
/**
* localStorage key an extension sets before calling createTab() to declare
* which northPane view the new tab should open with. The value must be the
* view ID passed to views.registerView() (e.g. "my-ext.northPane"). SqlEditor
* consumes and removes this key during initialization, then persists the chosen
* view ID under a per-tab key so the mode survives page reloads.
*
* @example
* // In an extension's newTab command handler:
* localStorage.setItem(PENDING_NORTH_PANE_VIEW_KEY, 'my-ext.northPane');
* sqlLab.createTab({ title: 'My View' });
*/
export const PENDING_NORTH_PANE_VIEW_KEY = 'sqllab.pendingNorthPaneView';

View File

@@ -161,19 +161,29 @@ const makeTab = (
catalog: string | null = null,
schema: string | null = null,
closed: boolean = false,
backendId?: string,
): Tab => {
const panels: Panel[] = []; // TODO: Populate panels
const editorGetter = closed
? () => Promise.reject(new Error(`Tab ${id} has been closed`))
: () => getEditorAsync(id);
return new Tab(id, name, dbId, catalog, schema, editorGetter, panels);
return new Tab(
id,
name,
dbId,
catalog,
schema,
editorGetter,
panels,
backendId,
);
};
const getTab = (id: string): Tab | undefined => {
const queryEditor = findQueryEditor(id);
if (queryEditor?.dbId !== undefined) {
const { name, dbId, catalog, schema } = queryEditor;
return makeTab(id, name, dbId, catalog, schema);
const { name, dbId, catalog, schema, tabViewId } = queryEditor;
return makeTab(id, name, dbId, catalog, schema, false, tabViewId);
}
return undefined;
};
@@ -441,6 +451,7 @@ const onDidCloseTab: typeof sqlLabApi.onDidCloseTab = (
action.queryEditor.catalog,
action.queryEditor.schema,
true, // closed
action.queryEditor.tabViewId,
),
thisArgs,
);
@@ -507,6 +518,8 @@ const onDidCreateTab: typeof sqlLabApi.onDidCreateTab = (
action.queryEditor.dbId ?? 0,
action.queryEditor.catalog,
action.queryEditor.schema ?? undefined,
false,
action.queryEditor.tabViewId,
),
thisArgs,
);
@@ -574,6 +587,8 @@ const createTab: typeof sqlLabApi.createTab = async (
newTab.dbId ?? 0,
newTab.catalog,
newTab.schema ?? undefined,
false,
newTab.tabViewId,
);
};

View File

@@ -34,6 +34,8 @@ export class Panel implements sqlLabType.Panel {
export class Tab implements sqlLabType.Tab {
id: string;
backendId?: string;
title: string;
databaseId: number;
@@ -54,6 +56,7 @@ export class Tab implements sqlLabType.Tab {
schema: string | null = null,
editorGetter: () => Promise<sqlLabType.Editor>,
panels: Panel[] = [],
backendId?: string,
) {
this.id = id;
this.title = title;
@@ -62,6 +65,7 @@ export class Tab implements sqlLabType.Tab {
this.schema = schema;
this.editorGetter = editorGetter;
this.panels = panels;
this.backendId = backendId;
}
getEditor(): Promise<sqlLabType.Editor> {

View File

@@ -561,10 +561,29 @@ test('createTab dispatches ADD_QUERY_EDITOR and returns the new tab', async () =
expect(tab).toBeDefined();
expect(tab.title).toBe('Custom Tab');
// A freshly created tab has no backend identifier until it syncs.
expect(tab.backendId).toBeUndefined();
const tabs = sqlLab.getTabs();
expect(tabs.length).toBeGreaterThanOrEqual(2);
});
test('getTabs leaves backendId undefined when the editor has no tabViewId', () => {
// The preloaded editor has no tabViewId, so its backendId stays undefined.
const [tab] = sqlLab.getTabs();
expect(tab.id).toBe(EDITOR_ID);
expect(tab.backendId).toBeUndefined();
});
test('getTabs surfaces the editor tabViewId as the tab backendId', () => {
// Stamp a backend id onto the editor and confirm it flows through to the tab.
(mockStore.getState().sqlLab.queryEditors[0] as QueryEditor).tabViewId =
'backend-42';
const tab = sqlLab.getCurrentTab();
expect(tab).toBeDefined();
expect(tab!.backendId).toBe('backend-42');
});
test('setActiveTab switches the active tab', async () => {
// Create a second tab first
await sqlLab.createTab({ title: 'Second Tab' });

View File

@@ -234,13 +234,13 @@ test('passes an absolute remoteEntry URL through unchanged', async () => {
script.restore();
});
test('logs error when initializeExtensions fails', async () => {
test('logs error and rejects when initializeExtensions fails', async () => {
const loader = ExtensionsLoader.getInstance();
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
const fetchError = new Error('Network error');
jest.spyOn(SupersetClient, 'get').mockRejectedValue(fetchError);
await loader.initializeExtensions();
await expect(loader.initializeExtensions()).rejects.toThrow('Network error');
expect(errorSpy).toHaveBeenCalledWith(
'Error setting up extensions:',

View File

@@ -74,7 +74,12 @@ class ExtensionsLoader {
);
logging.info('Extensions initialized successfully.');
} catch (error) {
// Reset so a later call can retry, and rethrow so callers (e.g.
// ExtensionsStartup) can surface the failure instead of it being
// swallowed here and the success path running regardless.
this.initializationPromise = null;
logging.error('Error setting up extensions:', error);
throw error;
}
})();
return this.initializationPromise;

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { render, waitFor, createStore } from 'spec/helpers/testing-library';
import reducerIndex from 'spec/helpers/reducerIndex';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import ExtensionsStartup from './ExtensionsStartup';
@@ -260,26 +261,23 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
initializeSpy.mockRestore();
});
test('continues rendering children even when ExtensionsLoader initialization fails', async () => {
test('renders children and surfaces a warning toast when init fails', async () => {
// Ensure feature flag is enabled
mockIsFeatureEnabled.mockReturnValue(true);
// Mock the initializeExtensions method to reject — ExtensionsLoader handles
// its own error logging internally
// Mock the initializeExtensions method to reject so the caller's .catch runs.
const originalInitialize = ExtensionsLoader.prototype.initializeExtensions;
ExtensionsLoader.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => Promise.resolve());
.mockRejectedValue(new Error('boom'));
const store = createStore(mockInitialState, reducerIndex);
const { container } = render(
<ExtensionsStartup>
<div data-testid="child" />
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
{ store, useRouter: true },
);
await waitFor(() => {
@@ -291,6 +289,17 @@ test('continues rendering children even when ExtensionsLoader initialization fai
).toBeInTheDocument();
});
// The failure must reach the user as a warning toast rather than being
// swallowed silently.
await waitFor(() => {
const { messageToasts } = store.getState() as unknown as {
messageToasts: { text: string }[];
};
expect(
messageToasts.some(toast => /Extensions failed to load/.test(toast.text)),
).toBe(true);
});
// Restore original method
ExtensionsLoader.prototype.initializeExtensions = originalInitialize;
});

View File

@@ -20,6 +20,7 @@ import { useEffect } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { t } from '@apache-superset/core/translation';
import {
authentication,
chat,
@@ -33,8 +34,9 @@ import {
sqlLab,
views,
} from 'src/core';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from 'src/views/store';
import { addWarningToast } from 'src/components/MessageToasts/actions';
import ExtensionsLoader from './ExtensionsLoader';
import 'src/extensions/Namespaces';
@@ -43,6 +45,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
}) => {
useNavigationTracker();
const dispatch = useDispatch();
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
@@ -67,9 +70,28 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
views,
};
// Load extensions without blocking the initial render (see #40915);
// surface any load failure as a warning toast instead of failing silently.
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
ExtensionsLoader.getInstance()
.initializeExtensions()
.then(() =>
supersetCore.utils.logging.info(
'Extensions initialized successfully.',
),
)
.catch((error: unknown) => {
supersetCore.utils.logging.error(
'Error setting up extensions:',
error,
);
dispatch(
addWarningToast(t('Extensions failed to load: %s', String(error))),
);
});
}
// dispatch is stable; intentionally only re-run when the user changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
return <>{children}</>;