Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions superset-frontend/packages/superset-core/src/sqlLab/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
rusackas marked this conversation as resolved.
Comment thread
rusackas marked this conversation as resolved.
Comment thread
rusackas marked this conversation as resolved.

/**
* The display title of the tab.
* This is what users see in the tab header.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';

Expand Down Expand Up @@ -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,
() => <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);
}
});

// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('with EstimateQueryCost enabled', () => {
beforeEach(() => {
Expand Down
97 changes: 97 additions & 0 deletions superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -271,6 +302,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]);
Comment thread
rusackas marked this conversation as resolved.

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(
Expand Down Expand Up @@ -1046,6 +1119,30 @@ const SqlEditor: FC<Props> = ({
'Choose one of the available databases from the panel on the left.',
)}
/>
) : northPaneViewId &&
northPaneViews.some(v => v.id === northPaneViewId) ? (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
`}
>
<SqlEditorTopBar
queryEditorId={queryEditor.id}
defaultPrimaryActions={null}
defaultSecondaryActions={[]}
/>
<div
css={css`
flex: 1;
overflow: auto;
padding: 0 ${theme.sizeUnit * 4}px;
`}
>
{resolveView(northPaneViewId)}
</div>
</div>
) : (
queryPane()
)}
Expand Down
118 changes: 101 additions & 17 deletions superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,22 @@
* 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';
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';

Expand Down Expand Up @@ -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 = (
<AddTabIconWrapper>
<Icons.PlusOutlined iconSize="l" data-test="add-tab-icon" />
</AddTabIconWrapper>
);

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.map(item => {
const command = commands.getCommand(item.command);
const Icon = command?.icon
? ((Icons as Record<string, typeof Icons.FileOutlined>)[
command.icon
] ?? Icons.FileOutlined)
: Icons.FileOutlined;
return {
key: command?.id ?? item.command,
label: command?.title ?? item.command,
icon: <Icon iconSize="m" />,
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 <button onClick={() => onEdit('add')}>.
// Stop propagation so antd doesn't also call newQueryEditor() while we handle it.
e.stopPropagation();
activate();
};

const handleKeyDown = (e: React.KeyboardEvent) => {
// The same wrapper button activates on Enter/Space; intercept those keys so
// keyboard users reach the extension new-tab dropdown rather than antd's
// default add-tab path.
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
activate();
}
};

return (
<Tooltip id="add-tab" placement="left" title={newTabTooltip}>
<Dropdown
open={open}
onOpenChange={setOpen}
menu={{ items: dropdownItems }}
trigger={[]}
>
<span
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{PlusIcon}
</span>
</Dropdown>
</Tooltip>
);
}

type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;

class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
Expand Down Expand Up @@ -235,21 +333,7 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
onTabClick={this.onTabClicked}
onEdit={this.handleEdit}
type={this.props.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)')
}
>
<AddTabIconWrapper>
<Icons.PlusOutlined iconSize="l" data-test="add-tab-icon" />
</AddTabIconWrapper>
</Tooltip>
}
addIcon={<NewTabButton onAddSqlEditor={() => this.newQueryEditor()} />}
items={tabItems}
/>
);
Expand Down
22 changes: 22 additions & 0 deletions superset-frontend/src/SqlLab/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
rusackas marked this conversation as resolved.
Loading
Loading