refactor: Make extensions contribution schema consistent (#37856)

This commit is contained in:
Michael S. Molina
2026-02-10 15:55:39 -03:00
committed by GitHub
parent 7ec5f1d7ec
commit b98b34a60f
18 changed files with 252 additions and 137 deletions

View File

@@ -38,12 +38,14 @@ Extensions can add new views or panels to the host application, such as custom S
"frontend": {
"contributions": {
"views": {
"sqllab.panels": [
{
"id": "my_extension.main",
"name": "My Panel Name"
}
]
"sqllab": {
"panels": [
{
"id": "my_extension.main",
"name": "My Panel Name"
}
]
}
}
}
}
@@ -76,25 +78,27 @@ Extensions can contribute new menu items or context menus to the host applicatio
"frontend": {
"contributions": {
"menus": {
"sqllab.editor": {
"primary": [
{
"view": "builtin.editor",
"command": "my_extension.copy_query"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "my_extension.prettify"
}
],
"context": [
{
"view": "builtin.editor",
"command": "my_extension.clear"
}
]
"sqllab": {
"editor": {
"primary": [
{
"view": "builtin.editor",
"command": "my_extension.copy_query"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "my_extension.prettify"
}
],
"context": [
{
"view": "builtin.editor",
"command": "my_extension.clear"
}
]
}
}
}
}

View File

@@ -92,12 +92,14 @@ The `extension.json` file contains all metadata necessary for the host applicati
"frontend": {
"contributions": {
"views": {
"sqllab.panels": [
{
"id": "dataset_references.main",
"name": "Dataset references"
}
]
"sqllab": {
"panels": [
{
"id": "dataset_references.main",
"name": "Dataset references"
}
]
}
}
},
"moduleFederation": {

View File

@@ -93,12 +93,14 @@ This example adds a "Data Profiler" panel to SQL Lab:
"frontend": {
"contributions": {
"views": {
"sqllab.panels": [
{
"id": "data_profiler.main",
"name": "Data Profiler"
}
]
"sqllab": {
"panels": [
{
"id": "data_profiler.main",
"name": "Data Profiler"
}
]
}
}
}
}
@@ -142,25 +144,27 @@ This example adds primary, secondary, and context actions to the editor:
}
],
"menus": {
"sqllab.editor": {
"primary": [
{
"view": "builtin.editor",
"command": "query_tools.format"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "query_tools.explain"
}
],
"context": [
{
"view": "builtin.editor",
"command": "query_tools.copy_as_cte"
}
]
"sqllab": {
"editor": {
"primary": [
{
"view": "builtin.editor",
"command": "query_tools.format"
}
],
"secondary": [
{
"view": "builtin.editor",
"command": "query_tools.explain"
}
],
"context": [
{
"view": "builtin.editor",
"command": "query_tools.copy_as_cte"
}
]
}
}
}
}

View File

@@ -94,12 +94,14 @@ The generated `extension.json` contains basic metadata. Update it to register yo
"frontend": {
"contributions": {
"views": {
"sqllab.panels": [
{
"id": "hello_world.main",
"name": "Hello World"
}
]
"sqllab": {
"panels": [
{
"id": "hello_world.main",
"name": "Hello World"
}
]
}
}
},
"moduleFederation": {

View File

@@ -56,19 +56,37 @@ class ModuleFederationConfig(BaseModel):
class ContributionConfig(BaseModel):
"""Configuration for frontend UI contributions."""
"""Configuration for frontend UI contributions.
Views and menus use a nested structure: type -> scope -> location -> contributions.
Example:
{
"views": {
"sqllab": {
"panels": [{"id": "my-ext.panel", "name": "My Panel"}],
"leftSidebar": [{"id": "my-ext.sidebar", "name": "Sidebar"}]
}
},
"menus": {
"sqllab": {
"editor": {"primary": [...], "secondary": [...]}
}
}
}
"""
commands: list[dict[str, Any]] = Field(
default_factory=list,
description="Command contributions",
)
views: dict[str, list[dict[str, Any]]] = Field(
views: dict[str, dict[str, list[dict[str, Any]]]] = Field(
default_factory=dict,
description="View contributions by location",
description="View contributions by scope and location",
)
menus: dict[str, Any] = Field(
menus: dict[str, dict[str, Any]] = Field(
default_factory=dict,
description="Menu contributions",
description="Menu contributions by scope and location",
)

View File

@@ -93,20 +93,55 @@ export interface EditorContribution {
*/
export type EditorLanguage = 'sql' | 'json' | 'yaml' | 'markdown' | 'css';
/**
* Valid locations within SQL Lab.
*/
export type SqlLabLocation =
| 'leftSidebar'
| 'rightSidebar'
| 'panels'
| 'editor'
| 'statusBar'
| 'results'
| 'queryHistory';
/**
* Nested structure for view contributions by scope and location.
* @example
* {
* sqllab: {
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
* }
* }
*/
export interface ViewContributions {
sqllab?: Partial<Record<SqlLabLocation, ViewContribution[]>>;
}
/**
* Nested structure for menu contributions by scope and location.
* @example
* {
* sqllab: {
* editor: { primary: [...], secondary: [...] }
* }
* }
*/
export interface MenuContributions {
sqllab?: Partial<Record<SqlLabLocation, MenuContribution>>;
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
*/
export interface Contributions {
/** List of command contributions. */
commands: CommandContribution[];
/** Mapping of menu contributions by menu key. */
menus: {
[key: string]: MenuContribution;
};
/** Mapping of view contributions by view key. */
views: {
[key: string]: ViewContribution[];
};
/** Nested mapping of menu contributions by scope and location. */
menus: MenuContributions;
/** Nested mapping of view contributions by scope and location. */
views: ViewContributions;
/** List of editor contributions. */
editors?: EditorContribution[];
}

View File

@@ -21,7 +21,6 @@ import { initialState } from 'src/SqlLab/fixtures';
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
import type { contributions, core } from '@apache-superset/core';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
import { ViewContribution } from 'src/SqlLab/contributions';
import AppLayout from './index';
jest.mock('src/components/ResizableSidebar/useStoredSidebarWidth');
@@ -156,7 +155,9 @@ test('renders right sidebar when RIGHT_SIDEBAR_VIEW_ID view is contributed', asy
commands: [],
menus: {},
views: {
[ViewContribution.RightSidebar]: [createMockView(viewId)],
sqllab: {
rightSidebar: [createMockView(viewId)],
},
},
},
});

View File

@@ -30,7 +30,7 @@ import {
SQL_EDITOR_LEFTBAR_WIDTH,
SQL_EDITOR_RIGHTBAR_WIDTH,
} from 'src/SqlLab/constants';
import { ViewContribution } from 'src/SqlLab/contributions';
import { ViewLocations } from 'src/SqlLab/contributions';
import ViewListExtension from 'src/components/ViewListExtension';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
@@ -98,7 +98,7 @@ const AppLayout: React.FC = ({ children }) => {
};
const contributions =
ExtensionsManager.getInstance().getViewContributions(
ViewContribution.RightSidebar,
ViewLocations.sqllab.rightSidebar,
) || [];
return (
@@ -139,7 +139,7 @@ const AppLayout: React.FC = ({ children }) => {
min={SQL_EDITOR_RIGHTBAR_WIDTH}
>
<ContentWrapper>
<ViewListExtension viewId={ViewContribution.RightSidebar} />
<ViewListExtension viewId={ViewLocations.sqllab.rightSidebar} />
</ContentWrapper>
</Splitter.Panel>
)}

View File

@@ -30,7 +30,7 @@ import { useEditorQueriesQuery } from 'src/hooks/apiResources/queries';
import useEffectEvent from 'src/hooks/useEffectEvent';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
import { ViewLocations } from 'src/SqlLab/contributions';
interface QueryHistoryProps {
queryEditorId: string | number;
@@ -121,7 +121,7 @@ const QueryHistory = ({
return editorQueries.length > 0 ? (
<>
<PanelToolbar viewId={ViewContribution.QueryHistory} />
<PanelToolbar viewId={ViewLocations.sqllab.queryHistory} />
<QueryTable
columns={[
'state',

View File

@@ -90,7 +90,7 @@ import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
import { ViewLocations } from 'src/SqlLab/contributions';
enum LimitingFactor {
Query = 'QUERY',
@@ -466,7 +466,7 @@ const ResultSet = ({
datasource={datasource}
/>
<PanelToolbar
viewId={ViewContribution.Results}
viewId={ViewLocations.sqllab.results}
defaultPrimaryActions={defaultPrimaryActions}
/>
</ResultSetButtons>

View File

@@ -28,7 +28,7 @@ import { removeTables, setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab';
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 { ViewLocations } from 'src/SqlLab/contributions';
import PanelToolbar from 'src/components/PanelToolbar';
import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
@@ -106,7 +106,7 @@ const SouthPane = ({
const dispatch = useDispatch();
const contributions =
ExtensionsManager.getInstance().getViewContributions(
ViewContribution.Panels,
ViewLocations.sqllab.panels,
) || [];
const { getView } = useExtensionsContext();
const { offline, tables } = useSelector(
@@ -242,7 +242,7 @@ const SouthPane = ({
padding: 8px;
`}
>
<PanelToolbar viewId={ViewContribution.Panels} />
<PanelToolbar viewId={ViewLocations.sqllab.panels} />
</Flex>
),
}}

View File

@@ -19,7 +19,7 @@
import { Flex } from '@superset-ui/core/components';
import { styled } from '@apache-superset/core/ui';
import { MenuItemType } from '@superset-ui/core/components/Menu';
import { ViewContribution } from 'src/SqlLab/contributions';
import { ViewLocations } from 'src/SqlLab/contributions';
import PanelToolbar from 'src/components/PanelToolbar';
const StyledFlex = styled(Flex)`
@@ -41,7 +41,7 @@ const SqlEditorTopBar = ({
<Flex gap="small" align="center">
<Flex gap="small" align="center">
<PanelToolbar
viewId={ViewContribution.Editor}
viewId={ViewLocations.sqllab.editor}
defaultPrimaryActions={defaultPrimaryActions}
defaultSecondaryActions={defaultSecondaryActions}
/>

View File

@@ -21,7 +21,7 @@ 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';
import { ViewLocations } from 'src/SqlLab/contributions';
const Container = styled(Flex)`
flex-direction: row-reverse;
@@ -40,14 +40,14 @@ const Container = styled(Flex)`
const StatusBar = () => {
const statusBarContributions =
ExtensionsManager.getInstance().getViewContributions(
ViewContribution.StatusBar,
ViewLocations.sqllab.statusBar,
) || [];
return (
<>
{statusBarContributions.length > 0 && (
<Container align="center" justify="space-between">
<ViewListExtension viewId={ViewContribution.StatusBar} />
<ViewListExtension viewId={ViewLocations.sqllab.statusBar} />
</Container>
)}
</>

View File

@@ -40,7 +40,7 @@ import type { SqlLabRootState } from 'src/SqlLab/types';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { addTable } from 'src/SqlLab/actions/sqlLab';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewContribution } from 'src/SqlLab/contributions';
import { ViewLocations } from 'src/SqlLab/contributions';
import TreeNodeRenderer from './TreeNodeRenderer';
import useTreeData, { EMPTY_NODE_ID_PREFIX } from './useTreeData';
import type { TreeNodeData } from './types';
@@ -244,7 +244,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
`}
>
<PanelToolbar
viewId={ViewContribution.LeftSidebar}
viewId={ViewLocations.sqllab.leftSidebar}
defaultPrimaryActions={
<>
<Button

View File

@@ -16,12 +16,35 @@
* specific language governing permissions and limitations
* under the License.
*/
export enum ViewContribution {
LeftSidebar = 'sqllab.leftSidebar',
RightSidebar = 'sqllab.rightSidebar',
Panels = 'sqllab.panels',
Editor = 'sqllab.editor',
StatusBar = 'sqllab.statusBar',
Results = 'sqllab.results',
QueryHistory = 'sqllab.queryHistory',
}
/**
* View locations for SQL Lab extension integration.
*
* These define the locations where extensions can contribute views and menus.
* The nested structure mirrors the extension.json contribution schema.
*
* @example
* // In extension.json:
* {
* "contributions": {
* "views": {
* "sqllab": {
* "panels": [{ "id": "my-ext.panel", "name": "My Panel" }]
* }
* }
* }
* }
*
* // In component code:
* <ViewListExtension viewId={ViewLocations.sqllab.panels} />
*/
export const ViewLocations = {
sqllab: {
leftSidebar: 'sqllab.leftSidebar',
rightSidebar: 'sqllab.rightSidebar',
panels: 'sqllab.panels',
editor: 'sqllab.editor',
statusBar: 'sqllab.statusBar',
results: 'sqllab.results',
queryHistory: 'sqllab.queryHistory',
},
} as const;

View File

@@ -36,7 +36,7 @@ function createMockView(
function createMockExtension(
options: Partial<core.Extension> & {
views?: Record<string, contributions.ViewContribution[]>;
views?: Record<string, Record<string, contributions.ViewContribution[]>>;
} = {},
): core.Extension {
const {
@@ -113,7 +113,9 @@ test('renders placeholder for unregistered view provider', async () => {
await createActivatedExtension(manager, {
views: {
[TEST_VIEW_ID]: [createMockView('test-view-1')],
test: {
view: [createMockView('test-view-1')],
},
},
});
@@ -127,10 +129,9 @@ test('renders multiple view placeholders for multiple contributions', async () =
await createActivatedExtension(manager, {
views: {
[TEST_VIEW_ID]: [
createMockView('test-view-1'),
createMockView('test-view-2'),
],
test: {
view: [createMockView('test-view-1'), createMockView('test-view-2')],
},
},
});
@@ -154,14 +155,18 @@ test('handles multiple extensions with views for same viewId', async () => {
await createActivatedExtension(manager, {
id: 'extension-1',
views: {
[TEST_VIEW_ID]: [createMockView('ext1-view')],
test: {
view: [createMockView('ext1-view')],
},
},
});
await createActivatedExtension(manager, {
id: 'extension-2',
views: {
[TEST_VIEW_ID]: [createMockView('ext2-view')],
test: {
view: [createMockView('ext2-view')],
},
},
});
@@ -178,8 +183,10 @@ test('renders views for different viewIds independently', async () => {
await createActivatedExtension(manager, {
views: {
[VIEW_ID_A]: [createMockView('view-a-component')],
[VIEW_ID_B]: [createMockView('view-b-component')],
view: {
a: [createMockView('view-a-component')],
b: [createMockView('view-b-component')],
},
},
});

View File

@@ -31,8 +31,8 @@ interface MockExtensionOptions {
exposedModules?: string[];
extensionDependencies?: string[];
commands?: contributions.CommandContribution[];
menus?: Record<string, contributions.MenuContribution>;
views?: Record<string, contributions.ViewContribution[]>;
menus?: Record<string, Record<string, contributions.MenuContribution>>;
views?: Record<string, Record<string, contributions.ViewContribution[]>>;
includeMockFunctions?: boolean;
}
@@ -360,14 +360,14 @@ test('initializeExtension handles extension without remoteEntry', async () => {
test('getMenuContributions returns undefined initially', () => {
const manager = ExtensionsManager.getInstance();
const menuContributions = manager.getMenuContributions('nonexistent');
const menuContributions = manager.getMenuContributions('non.existent');
expect(menuContributions).toBeUndefined();
});
test('getViewContributions returns undefined initially', () => {
const manager = ExtensionsManager.getInstance();
const viewContributions = manager.getViewContributions('nonexistent');
const viewContributions = manager.getViewContributions('non.existent');
expect(viewContributions).toBeUndefined();
});
@@ -505,16 +505,20 @@ test('handles contributions with menu items', async () => {
createMockCommand('ext1.command2'),
],
menus: {
testMenu: createMockMenu({
primary: [
createMockMenuItem('test-view', 'ext1.command1'),
createMockMenuItem('test-view2', 'ext1.command2'),
],
secondary: [createMockMenuItem('test-view3', 'ext1.command1')],
}),
test: {
menu: createMockMenu({
primary: [
createMockMenuItem('test-view', 'ext1.command1'),
createMockMenuItem('test-view2', 'ext1.command2'),
],
secondary: [createMockMenuItem('test-view3', 'ext1.command1')],
}),
},
},
views: {
testView: [createMockView('test-view-1'), createMockView('test-view-2')],
test: {
view: [createMockView('test-view-1'), createMockView('test-view-2')],
},
},
});
@@ -525,13 +529,13 @@ test('handles contributions with menu items', async () => {
expect(commands.find(cmd => cmd.command === 'ext1.command2')).toBeDefined();
// Test menu contributions
const menuContributions = manager.getMenuContributions('testMenu');
const menuContributions = manager.getMenuContributions('test.menu');
expect(menuContributions).toBeDefined();
expect(menuContributions?.primary).toHaveLength(2);
expect(menuContributions?.secondary).toHaveLength(1);
// Test view contributions
const viewContributions = manager.getViewContributions('testView');
const viewContributions = manager.getViewContributions('test.view');
expect(viewContributions).toBeDefined();
expect(viewContributions).toHaveLength(2);
});
@@ -539,8 +543,8 @@ test('handles contributions with menu items', async () => {
test('handles non-existent menu and view contributions', () => {
const manager = ExtensionsManager.getInstance();
expect(manager.getMenuContributions('nonexistent')).toBeUndefined();
expect(manager.getViewContributions('nonexistent')).toBeUndefined();
expect(manager.getMenuContributions('non.existent')).toBeUndefined();
expect(manager.getViewContributions('non.existent')).toBeUndefined();
expect(manager.getCommandContribution('nonexistent.command')).toBeUndefined();
});

View File

@@ -22,7 +22,9 @@ import type { contributions, core } from '@apache-superset/core';
import { ExtensionContext } from '../core/models';
type MenuContribution = contributions.MenuContribution;
type MenuContributions = contributions.MenuContributions;
type ViewContribution = contributions.ViewContribution;
type ViewContributions = contributions.ViewContributions;
type CommandContribution = contributions.CommandContribution;
type EditorContribution = contributions.EditorContribution;
type Extension = core.Extension;
@@ -37,8 +39,8 @@ class ExtensionsManager {
private extensionContributions: Map<
string,
{
menus?: Record<string, MenuContribution>;
views?: Record<string, ViewContribution[]>;
menus?: MenuContributions;
views?: ViewContributions;
commands?: CommandContribution[];
editors?: EditorContribution[];
}
@@ -232,18 +234,24 @@ class ExtensionsManager {
/**
* Retrieves menu contributions for a specific key.
* @param key The key of the menu contributions.
* @param key The key of the menu contributions in format "scope.location" (e.g., "sqllab.editor").
* @returns The menu contributions matching the key, or undefined if not found.
*/
public getMenuContributions(key: string): MenuContribution | undefined {
const [scope, location] = key.split('.');
if (!scope || !location) {
return undefined;
}
const merged: MenuContribution = {
context: [],
primary: [],
secondary: [],
};
for (const ext of this.extensionContributions.values()) {
if (ext.menus && ext.menus[key]) {
const menu = ext.menus[key];
const scopeMenus = ext.menus?.[scope as keyof MenuContributions];
const menu =
scopeMenus?.[location as keyof NonNullable<typeof scopeMenus>];
if (menu) {
if (menu.context) merged.context!.push(...menu.context);
if (menu.primary) merged.primary!.push(...menu.primary);
if (menu.secondary) merged.secondary!.push(...menu.secondary);
@@ -261,14 +269,21 @@ class ExtensionsManager {
/**
* Retrieves view contributions for a specific key.
* @param key The key of the view contributions.
* @param key The key of the view contributions in format "scope.location" (e.g., "sqllab.panels").
* @returns An array of view contributions matching the key, or undefined if not found.
*/
public getViewContributions(key: string): ViewContribution[] | undefined {
const [scope, location] = key.split('.');
if (!scope || !location) {
return undefined;
}
let result: ViewContribution[] = [];
for (const ext of this.extensionContributions.values()) {
if (ext.views && ext.views[key]) {
result = result.concat(ext.views[key]);
const scopeViews = ext.views?.[scope as keyof ViewContributions];
const views =
scopeViews?.[location as keyof NonNullable<typeof scopeViews>];
if (views) {
result = result.concat(views);
}
}
return result.length > 0 ? result : undefined;