mirror of
https://github.com/apache/superset.git
synced 2026-07-05 14:25:32 +00:00
Compare commits
14 Commits
fix/105973
...
feat/sqlla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ee68d89f3 | ||
|
|
5a4afe78c7 | ||
|
|
29d8f9da8a | ||
|
|
8b86bbc95d | ||
|
|
27f05d7639 | ||
|
|
e3b4ac215a | ||
|
|
a17472bbb1 | ||
|
|
1181df7fd6 | ||
|
|
276cae3ba7 | ||
|
|
d817a3be77 | ||
|
|
7af82a9041 | ||
|
|
0378c85ed9 | ||
|
|
0c7b5d34f2 | ||
|
|
72e2340715 |
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
Reference in New Issue
Block a user