mirror of
https://github.com/apache/superset.git
synced 2026-06-10 01:59:17 +00:00
Compare commits
5 Commits
chore/add-
...
chat-proto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793ffb3d80 | ||
|
|
f575fdae3a | ||
|
|
7b418becc7 | ||
|
|
ba7db15f02 | ||
|
|
c85661f4fd |
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
@@ -17,23 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Manifest schema for Superset extension contributions.
|
||||
*
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import type { ChatbotView } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
|
||||
/**
|
||||
* Valid locations within SQL Lab.
|
||||
*/
|
||||
export type { ChatbotView };
|
||||
|
||||
export type SqlLabLocation =
|
||||
| 'leftSidebar'
|
||||
| 'rightSidebar'
|
||||
@@ -43,43 +32,14 @@ export type SqlLabLocation =
|
||||
| '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" }]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
/** Valid locations within the app shell (persist across all routes). */
|
||||
export type AppLocation = 'chatbot';
|
||||
|
||||
export interface ViewContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested structure for menu contributions by scope and location.
|
||||
* @example
|
||||
* {
|
||||
* sqllab: {
|
||||
* editor: { primary: [...], secondary: [...] }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface MenuContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
commands: Command[];
|
||||
/** Nested mapping of menu contributions by scope and location. */
|
||||
menus: MenuContributions;
|
||||
/** Nested mapping of view contributions by scope and location. */
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
}
|
||||
|
||||
@@ -20,19 +20,12 @@
|
||||
/**
|
||||
* @fileoverview Views registration API for Superset extensions.
|
||||
*
|
||||
* This module provides functions for registering custom React views
|
||||
* at specific locations in the Superset UI. Views are registered as
|
||||
* module-level side effects at import time.
|
||||
* Extensions register React views at named locations using `registerView`.
|
||||
* Registrations happen as module-level side effects at import time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { views } from '@apache-superset/core';
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
* Built-in locations:
|
||||
* - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
|
||||
* - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders one)
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
@@ -48,20 +41,23 @@ export interface View {
|
||||
name: string;
|
||||
/** Optional description of the view, for display in contribution manifests. */
|
||||
description?: string;
|
||||
/**
|
||||
* Optional icon identifier for the view, used in admin pickers and manifest
|
||||
* listings. Static — set once at registerView() time.
|
||||
* Dynamic icon states (e.g. notification badge) are the extension's concern.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom view at a specific UI location.
|
||||
*
|
||||
* The view provider function is called when the UI renders the location,
|
||||
* and should return a React element to display.
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param view The view descriptor (id, name, and optional icon/description).
|
||||
* @param location The location where this view should appear.
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
* @example SQL Lab panel
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
@@ -69,6 +65,15 @@ export interface View {
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||
* 'superset.chatbot',
|
||||
* () => <ChatbotApp />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
@@ -76,6 +81,21 @@ export declare function registerView(
|
||||
provider: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
|
||||
*
|
||||
* Extension authors should use this type when calling `registerView` for the
|
||||
* chatbot area. It is identical to {@link View} but makes the registration
|
||||
* intent explicit and allows future narrowing (e.g. required `icon`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
|
||||
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
|
||||
* ```
|
||||
*/
|
||||
export type ChatbotView = View;
|
||||
|
||||
/**
|
||||
* Retrieves all views registered at a specific location.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { views } from 'src/core';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import ChatbotMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('renders nothing when no chatbot extension is registered', () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the registered chatbot inside the fixed mount slot', () => {
|
||||
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first-to-register chatbot when several are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First Bubble');
|
||||
const secondProvider = () =>
|
||||
React.createElement('div', null, 'Second Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByText('First Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing chatbot so it does not crash the host', () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => React.createElement(FailingChatbot),
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
});
|
||||
82
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
82
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host mount point for the singleton `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* The host owns the slot: a fixed bottom-right anchor that persists across all
|
||||
* routes, with a managed z-index. The extension owns everything rendered
|
||||
* inside it — the collapsed bubble, the expanded panel, all open/close state,
|
||||
* animations, and behavior (SIP §3.2 "Component contract").
|
||||
*
|
||||
* Singleton resolution (which of possibly several registered chatbots renders)
|
||||
* is delegated to `getActiveChatbot`. If no chatbot extension is registered,
|
||||
* this component renders nothing and the corner stays empty.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { getActiveChatbot } from 'src/core/chatbot';
|
||||
import { subscribeToLocation } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
|
||||
const CHATBOT_EDGE_MARGIN = 24;
|
||||
|
||||
/**
|
||||
* Renders the active chatbot extension into a fixed bottom-right slot.
|
||||
*
|
||||
* Mounted once at the app root so the bubble persists across routes.
|
||||
* Re-resolves when the chatbot registry changes (extension activated or
|
||||
* deactivated at runtime via the P1.A lifecycle contract).
|
||||
* Renders null when no chatbot extension is registered.
|
||||
*/
|
||||
const ChatbotMount = () => {
|
||||
const theme = useTheme();
|
||||
const [activeChatbot, setActiveChatbot] = useState(getActiveChatbot);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToLocation(CHATBOT_LOCATION, () =>
|
||||
setActiveChatbot(getActiveChatbot()),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!activeChatbot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chatbot-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getActiveChatbot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||
});
|
||||
|
||||
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
expect(active?.provider).toBe(firstProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||
const provider = () => React.createElement('div', null, 'Panel');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'some.panel', name: 'Some Panel' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
const disposable = views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
77
superset-frontend/src/core/chatbot/index.ts
Normal file
77
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* `superset.chatbot` is a singleton contribution area: multiple chatbot
|
||||
* extensions may register a view there, but the host renders exactly one.
|
||||
* This module owns the host-side selection policy.
|
||||
*
|
||||
* This is host-internal infrastructure — it is NOT part of the public
|
||||
* `@apache-superset/core` API. Extensions register via the public
|
||||
* `views.registerView()`; only the host resolves which one is active.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The resolved active chatbot: a view id paired with its renderable provider.
|
||||
*/
|
||||
export interface ActiveChatbot {
|
||||
/** The registered view id of the selected chatbot. */
|
||||
id: string;
|
||||
/** The provider that renders the chatbot's React element. */
|
||||
provider: () => ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which single chatbot extension is currently active.
|
||||
*
|
||||
* Selection policy (P1):
|
||||
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
|
||||
* - If one or more chatbots are registered, the first one to register wins.
|
||||
*
|
||||
* `Set` preserves insertion order, so "first to register" is deterministic.
|
||||
*
|
||||
* This is the P1 fallback policy. P2 introduces an admin "Default chatbot"
|
||||
* setting (SIP §4 option (c)); when that lands, the admin-selected id takes
|
||||
* precedence here and this first-to-register behavior remains only as the
|
||||
* fallback used when no admin setting is configured.
|
||||
*
|
||||
* @returns The active chatbot's id and provider, or `undefined` if none.
|
||||
*/
|
||||
export const getActiveChatbot = (): ActiveChatbot | undefined => {
|
||||
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
|
||||
if (registeredIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Deterministic first-to-register fallback. P2 will consult the admin
|
||||
// "Default chatbot" setting before this point.
|
||||
const [selectedId] = registeredIds;
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id: selectedId, provider };
|
||||
};
|
||||
@@ -17,7 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views, resolveView } from './index';
|
||||
import {
|
||||
views,
|
||||
resolveView,
|
||||
getViewProvider,
|
||||
getRegisteredViewIds,
|
||||
} from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
|
||||
|
||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns the registered provider for a matching location', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined when the location does not match', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Registered, but at a different location.
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined for an unknown id', () => {
|
||||
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns ids in registration order', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
|
||||
'first.chatbot',
|
||||
'second.chatbot',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns an empty array for an unused location', () => {
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
/** Listeners notified whenever a view is registered or unregistered at a location. */
|
||||
const locationListeners: Map<string, Set<() => void>> = new Map();
|
||||
|
||||
const notifyListeners = (location: string) => {
|
||||
locationListeners.get(location)?.forEach(fn => fn());
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes at a specific location.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export const subscribeToLocation = (
|
||||
location: string,
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
const listeners = locationListeners.get(location) ?? new Set();
|
||||
listeners.add(listener);
|
||||
locationListeners.set(location, listeners);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
notifyListeners(location);
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyListeners(location);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,6 +101,53 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal accessor that returns the registered `provider` for a view id
|
||||
* at a given location.
|
||||
*
|
||||
* This is deliberately NOT part of the public `@apache-superset/core` `views`
|
||||
* API. The public `getViews` returns descriptors only (`id`/`name`/...), so an
|
||||
* extension can discover what is registered but cannot obtain — and therefore
|
||||
* cannot render — another extension's view outside the host's mount point,
|
||||
* lifecycle, and fault-isolation boundary.
|
||||
*
|
||||
* The host uses this accessor to render exclusive (singleton) contribution
|
||||
* areas such as `superset.chatbot`, where it must enumerate the candidates and
|
||||
* then render exactly one. See `getActiveChatbot` in `src/core/chatbot`.
|
||||
*
|
||||
* @param location The contribution location (e.g. `superset.chatbot`).
|
||||
* @param id The registered view id.
|
||||
* @returns The provider function, or undefined if no matching view is
|
||||
* registered at that location.
|
||||
*/
|
||||
export const getViewProvider = (
|
||||
location: string,
|
||||
id: string,
|
||||
): (() => ReactElement) | undefined => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (entry?.location !== location) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal accessor that returns the ordered list of view ids registered
|
||||
* at a location, in registration order.
|
||||
*
|
||||
* Registration order is meaningful for exclusive locations: the host's
|
||||
* deterministic fallback policy ("first to register wins") relies on it.
|
||||
* Like {@link getViewProvider}, this is host-internal and not part of the
|
||||
* public API.
|
||||
*
|
||||
* @param location The contribution location.
|
||||
* @returns View ids in registration order, or an empty array if none.
|
||||
*/
|
||||
export const getRegisteredViewIds = (location: string): string[] => {
|
||||
const ids = locationIndex.get(location);
|
||||
return ids ? Array.from(ids) : [];
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
|
||||
type Extension = core.Extension;
|
||||
|
||||
@@ -36,6 +39,9 @@ class ExtensionsLoader {
|
||||
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
/** Disposables returned by contribution registrations, keyed by extension id. */
|
||||
private extensionDisposables: Map<string, (() => void)[]> = new Map();
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -88,7 +94,8 @@ class ExtensionsLoader {
|
||||
public async initializeExtension(extension: Extension) {
|
||||
try {
|
||||
if (extension.remoteEntry) {
|
||||
await this.loadModule(extension);
|
||||
const disposables = await this.loadModule(extension);
|
||||
this.extensionDisposables.set(extension.id, disposables);
|
||||
}
|
||||
this.extensionIndex.set(extension.id, extension);
|
||||
} catch (error) {
|
||||
@@ -96,15 +103,31 @@ class ExtensionsLoader {
|
||||
`Failed to initialize extension ${extension.name}\n`,
|
||||
error,
|
||||
);
|
||||
store.dispatch(
|
||||
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates an extension by disposing all of its registered contributions
|
||||
* and removing it from the index.
|
||||
*/
|
||||
public deactivateExtension(id: string): void {
|
||||
const disposables = this.extensionDisposables.get(id);
|
||||
if (disposables) {
|
||||
disposables.forEach(dispose => dispose());
|
||||
this.extensionDisposables.delete(id);
|
||||
}
|
||||
this.extensionIndex.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a single extension module via webpack module federation.
|
||||
* The module's top-level side effects fire contribution registrations.
|
||||
* @param extension The extension to load.
|
||||
*/
|
||||
private async loadModule(extension: Extension): Promise<void> {
|
||||
private async loadModule(extension: Extension): Promise<(() => void)[]> {
|
||||
const { remoteEntry, id } = extension;
|
||||
|
||||
// Load the remote entry script
|
||||
@@ -149,8 +172,33 @@ class ExtensionsLoader {
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
|
||||
// Intercept contribution registrations during module activation so we can
|
||||
// collect the Disposables and drive cleanup on deactivation.
|
||||
const collected: (() => void)[] = [];
|
||||
const originalSuperset = window.superset;
|
||||
window.superset = {
|
||||
...originalSuperset,
|
||||
views: {
|
||||
...originalSuperset.views,
|
||||
registerView: (
|
||||
...args: Parameters<typeof originalSuperset.views.registerView>
|
||||
) => {
|
||||
const disposable = originalSuperset.views.registerView(...args);
|
||||
collected.push(() => disposable.dispose());
|
||||
return disposable;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute the module factory — side effects fire contribution registrations
|
||||
factory();
|
||||
} finally {
|
||||
window.superset = originalSuperset;
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
@@ -80,14 +81,29 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
views,
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
// Isolate unhandled rejections that originate from extension code so they
|
||||
// cannot crash the host application. Extensions load via Module Federation
|
||||
// and their async failures (e.g. failed API calls, unhandled promise
|
||||
// chains) would otherwise surface as uncaught rejections in the host.
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
// Always log so extension authors can diagnose failures.
|
||||
logging.error('[extensions] Unhandled rejection from extension:', event.reason);
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
setup();
|
||||
// Render the host immediately; extension bundles load in the background.
|
||||
// ChatbotMount re-resolves reactively once the chatbot extension registers
|
||||
// (via subscribeToLocation), so the bubble appears without blocking the UI.
|
||||
setInitialized(true);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
|
||||
import ChatbotMount from 'src/components/ChatbotMount';
|
||||
import { RootContextProviders } from './RootContextProviders';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
|
||||
@@ -112,6 +113,13 @@ const App = () => (
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
{/*
|
||||
The singleton chatbot bubble. Rendered as a sibling of the route
|
||||
Switch — inside ExtensionsStartup so chatbot extensions have been
|
||||
loaded and registered, but outside the Switch so the bubble persists
|
||||
across route changes (SIP §3.2).
|
||||
*/}
|
||||
<ChatbotMount />
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
|
||||
31
superset-frontend/src/views/contributions.ts
Normal file
31
superset-frontend/src/views/contributions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* View locations for app-shell extension integration.
|
||||
*
|
||||
* These define locations that persist across all routes, mirroring the `app`
|
||||
* scope of the `ViewContributions` manifest schema.
|
||||
*/
|
||||
export const AppViewLocations = {
|
||||
app: {
|
||||
chatbot: 'superset.chatbot',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;
|
||||
Reference in New Issue
Block a user