diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 38920d41e7fb..c36bc8deee57 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -8450,9 +8450,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8470,9 +8467,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8490,9 +8484,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8510,9 +8501,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8530,9 +8518,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8550,9 +8535,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8570,9 +8552,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8590,9 +8569,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -26132,6 +26108,21 @@ } } }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/jsdom/node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -43244,6 +43235,21 @@ } } }, + "node_modules/whatwg-url/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/whatwg-url/node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 22f6c4f8758f..887a4acd750b 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -18,6 +18,14 @@ "types": "./lib/authentication/index.d.ts", "default": "./lib/authentication/index.js" }, + "./chat": { + "types": "./lib/chat/index.d.ts", + "default": "./lib/chat/index.js" + }, + "./navigation": { + "types": "./lib/navigation/index.d.ts", + "default": "./lib/navigation/index.js" + }, "./commands": { "types": "./lib/commands/index.d.ts", "default": "./lib/commands/index.js" diff --git a/superset-frontend/packages/superset-core/src/chat/index.ts b/superset-frontend/packages/superset-core/src/chat/index.ts new file mode 100644 index 000000000000..92f65b70ee46 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -0,0 +1,184 @@ +/** + * 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 Chat contribution API for Superset extensions. + * + * Chat is a dedicated contribution type (not a view): an extension registers + * a chat via {@link registerChat} and the host owns where and how it is + * mounted. The host applies singleton resolution — multiple chat extensions + * may register, but exactly one is active at a time. + * + * @example + * ```typescript + * import { chat } from '@apache-superset/core'; + * + * chat.registerChat( + * { id: 'acme.chat', name: 'Acme Chat' }, + * () => , + * () => , + * ); + * ``` + */ + +import { ReactElement } from 'react'; +import type { Disposable, Event } from '../common'; + +export interface Chat { + /** The unique identifier for the chat. */ + id: string; + /** The display name of the chat. */ + name: string; + /** Optional description of the chat, for display in contribution manifests. */ + description?: string; +} + +export type DisplayMode = 'floating' | 'panel'; + +/** + * Registers a chat provider. The host applies singleton resolution — only one + * chat is active at a time: the most recently registered chat wins, and + * disposing it restores the previously registered one. Re-registering an id + * replaces that registration in place. + * + * When a registration with a different id takes over the active slot (or the + * active chat is disposed), the host closes the panel first, firing + * {@link onDidClose}; an in-place same-id replacement keeps the open state. + * + * Disposing the returned Disposable unregisters the chat. + * + * @param chat The chat descriptor (id, name). + * @param trigger A function returning the collapsed bubble element. Owned by + * the extension — dynamic state such as unread counts and badges lives here. + * Hidden by the host when in panel mode. + * @param panel A function returning the chat panel element. Mounted by the + * host as a floating overlay in 'floating' mode, or docked at the side of + * the viewport in 'panel' mode (the reference host docks a fixed-width + * overlay at the right edge; hosts may integrate a true layout slot + * instead). Same component in both modes. + * @returns A Disposable that unregisters the chat when disposed. + * + * @example + * ```typescript + * chat.registerChat( + * { id: 'acme.chat', name: 'Acme Chat' }, + * () => , + * () => , + * ); + * ``` + */ +export declare function registerChat( + chat: Chat, + trigger: () => ReactElement, + panel: () => ReactElement, +): Disposable; + +/** + * Returns the active chat descriptor. + * + * @returns A copy of the active Chat descriptor, or undefined if none is + * registered. Mutating the returned object has no effect on the registry. + */ +export declare function getChat(): Chat | undefined; + +/** + * Event fired when a chat is registered. + */ +export declare const onDidRegisterChat: Event; + +/** + * Event fired when a chat is unregistered. + */ +export declare const onDidUnregisterChat: Event; + +/** + * Opens the active chat's panel. + * + * Acts on whichever chat is active, regardless of which extension calls it. + * No-op when no chat is registered or the panel is already open. + */ +export declare function open(): void; + +/** + * Closes the active chat's panel. + * + * Acts on whichever chat is active, regardless of which extension calls it. + * No-op when the panel is not open. + */ +export declare function close(): void; + +/** + * Returns whether the active chat's panel is currently open. + * + * @returns True if the chat panel is open. + */ +export declare function isOpen(): boolean; + +/** + * Event fired when the chat panel opens. Also fired by the host's own + * controls, not only by an extension's open() call. + */ +export declare const onDidOpen: Event; + +/** + * Event fired when the chat panel closes. Also fired when the host closes the + * panel itself, e.g. because the active chat was disposed or displaced by a + * different chat. + */ +export declare const onDidClose: Event; + +/** + * Returns the current display mode. + * + * @returns The current DisplayMode. + */ +export declare function getDisplayMode(): DisplayMode; + +/** + * Sets the display mode. + * + * The mode is host-global and applies to whichever chat is active, regardless + * of which extension calls it. Hosts may also change the mode through their + * own controls — use onDidChangeDisplayMode to observe all changes rather than + * assuming the last setDisplayMode() call won. + * + * @param displayMode The display mode to switch to. + */ +export declare function setDisplayMode(displayMode: DisplayMode): void; + +/** + * Event fired when the display mode changes, whether triggered by an + * extension via setDisplayMode() or by host-provided controls. + */ +export declare const onDidChangeDisplayMode: Event; + +/** + * Event fired when the panel is resized in panel mode. + * + * The host owns the resizer handle and drag interaction; a host without a + * resizer never fires this event. (The reference host mounts the panel at a + * fixed width and does not provide a resizer, so subscribers receive no + * events there.) Listen to this event to adapt internal layout to the + * available width; do not rely on it firing. + */ +export declare const onDidResizePanel: Event<{ width: number }>; + +// TODO: client actions API — tool availability functions will be added here +// once the client_actions SIP is finalized. The chat namespace is the +// intended integration point between the two SIPs. diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index cc399cf62751..7f28aba584b3 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -223,8 +223,6 @@ export interface Extension { dependencies: string[]; /** Human-readable description of the extension */ description: string; - /** List of other extensions that this extension depends on */ - extensionDependencies: string[]; /** Unique identifier for the extension */ id: string; /** Human-readable name of the extension */ diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index faccbb305dc8..f24ae36dba0a 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -23,9 +23,10 @@ * 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. + * menus, editors, chat) and re-exported here for the manifest schema. */ +import { Chat } from '../chat'; import { Command } from '../commands'; import { View } from '../views'; import { Menu } from '../menus'; @@ -71,7 +72,8 @@ export interface MenuContributions { } /** - * Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module. + * Aggregates all contributions (commands, menus, views, editors, and chat) + * provided by an extension or module. */ export interface Contributions { /** List of commands. */ @@ -82,4 +84,10 @@ export interface Contributions { views: ViewContributions; /** List of editors. */ editors?: Editor[]; + /** + * The chat contributed by the extension — at most one per extension, since + * the host applies singleton resolution and renders exactly one active + * chat at a time. + */ + chat?: Chat; } diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 75863372409e..be41ac88a196 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -18,10 +18,12 @@ */ export * as common from './common'; export * as authentication from './authentication'; +export * as chat from './chat'; export * as commands from './commands'; export * as editors from './editors'; export * as extensions from './extensions'; export * as menus from './menus'; +export * as navigation from './navigation'; export * as sqlLab from './sqlLab'; export * as views from './views'; export * as contributions from './contributions'; diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts new file mode 100644 index 000000000000..585a87f2e9fc --- /dev/null +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -0,0 +1,81 @@ +/** + * 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 Navigation namespace for Superset extensions. + * + * Exposes the current application surface so extensions can react to route + * changes without polling. Entity-level context (chart, dashboard, dataset) + * is intentionally not included here — surface-specific namespaces that + * resolve entity payloads are introduced in later phases. + */ + +import { Event } from '../common'; + +/** + * The set of top-level application surfaces. + * + * `'explore'`, `'dashboard'` and `'dataset'` are the single-entity + * editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and + * `'dataset_list'` are the browse/list surfaces, distinct from those because no + * single entity is active. `'sqllab'` is the SQL editor where + * `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'` + * are the related SQL Lab browse pages, which are not the editor. `'home'` is + * the welcome surface and the fallback for any route not explicitly enumerated. + */ +export type Page = + | 'dashboard' + | 'dashboard_list' + | 'explore' + | 'chart_list' + | 'sqllab' + | 'query_history' + | 'saved_queries' + | 'dataset' + | 'dataset_list' + | 'home'; + +/** + * Returns the current page surface. + * + * @example + * ```typescript + * const page = navigation.getPage(); + * if (page === 'dashboard') { + * // react to being on a dashboard surface + * } + * ``` + */ +export declare function getPage(): Page; + +/** + * Event fired whenever the user navigates to a different surface. + * + * @example + * ```typescript + * const sub = navigation.onDidChangePage(page => { + * if (page === 'dashboard') { + * // react to navigating onto a dashboard surface + * } + * }); + * // later: + * sub.dispose(); + * ``` + */ +export declare const onDidChangePage: Event; diff --git a/superset-frontend/src/components/ChatMount/ChatMount.test.tsx b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx new file mode 100644 index 000000000000..9a940baa80f9 --- /dev/null +++ b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx @@ -0,0 +1,287 @@ +/** + * 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 { act, render, screen } from 'spec/helpers/testing-library'; +import { chat } from 'src/core/chat'; +import ChatMount from '.'; + +const disposables: Array<{ dispose: () => void }> = []; + +afterEach(() => { + act(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + // Reset host-owned state shared across tests in this module. + chat.close(); + chat.setDisplayMode('floating'); + }); +}); + +test('renders nothing when no chat extension is registered', () => { + render(); + + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); +}); + +test('renders the trigger bubble of the registered chat', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); + // The panel stays unmounted until the chat is opened. + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); +}); + +test('mounts the panel when the chat opens and unmounts it on close', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + act(() => chat.open()); + + expect(screen.getByText('Acme Panel')).toBeInTheDocument(); + // In floating mode the trigger stays mounted alongside the open panel. + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); + + act(() => chat.close()); + + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); +}); + +test('renders the last-registered chat when several are installed', () => { + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ), + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + + render(); + + // Last-loaded wins: the second registration takes over the singleton slot. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); + expect(screen.queryByText('First Bubble')).not.toBeInTheDocument(); +}); + +test('reacts to a chat registering after the initial render', () => { + render(); + + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + }); + + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('a takeover mounts the incoming chat closed', () => { + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ), + ); + + render(); + act(() => chat.open()); + expect(screen.getByText('First Panel')).toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + }); + + // The displaced chat's open state must not leak into the winner. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); + expect(screen.queryByText('Second Panel')).not.toBeInTheDocument(); + expect(screen.queryByText('First Panel')).not.toBeInTheDocument(); +}); + +test('panel mode docks the open panel and hides the trigger', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + act(() => { + chat.setDisplayMode('panel'); + chat.open(); + }); + + expect(screen.getByText('Acme Panel')).toBeInTheDocument(); + expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument(); + + act(() => chat.close()); + + // A closed chat in panel mode renders nothing — the trigger is hidden too. + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); +}); + +test('a crashing panel does not take the trigger down with it', () => { + const FailingPanel = () => { + throw new Error('panel blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () => , + ), + ); + + render(); + act(() => chat.open()); + + // The panel's boundary contains the crash; the trigger keeps rendering so + // the user is not stranded without a way back. + expect(screen.queryByText('panel blew up')).not.toBeInTheDocument(); + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('isolates a failing trigger so it does not crash the host', () => { + const FailingTrigger = () => { + throw new Error('chat blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + // The host-owned error boundary catches the failure; render does not throw. + expect(() => render()).not.toThrow(); + // The mount slot still renders (the boundary lives inside it), confirming + // the provider was actually exercised and contained. + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); +}); + +test('isolates a chat whose provider function itself throws', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => { + throw new Error('provider blew up'); + }, + () =>
Acme Panel
, + ), + ); + + // ChatRenderer wraps provider() in a component so ErrorBoundary catches + // synchronous throws from the provider function, not just from its output. + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); +}); + +test('recovers from a crashed chat when a different chat takes over', () => { + const FailingTrigger = () => { + throw new Error('first chat blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () => , + () =>
First Panel
, + ), + ); + + render(); + expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + }); + + // The boundary is keyed per registration, so the latched crash from the + // first chat does not blank the second one. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); +}); + +test('recovers when a crashed chat re-registers a fixed version under the same id', () => { + const FailingTrigger = () => { + throw new Error('broken release'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () =>
Fixed Bubble
, + () =>
Acme Panel
, + ), + ); + }); + + // Same id, new registrationId: the remounted boundary renders the fix. + expect(screen.getByText('Fixed Bubble')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/ChatMount/index.tsx b/superset-frontend/src/components/ChatMount/index.tsx new file mode 100644 index 000000000000..4f9c96287381 --- /dev/null +++ b/superset-frontend/src/components/ChatMount/index.tsx @@ -0,0 +1,149 @@ +/** + * 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 { type ReactElement, useRef, useSyncExternalStore } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { logging } from '@apache-superset/core/utils'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { store } from 'src/views/store'; +import { getChatSnapshot, subscribeToChatState } from 'src/core/chat'; + +const CHAT_EDGE_MARGIN = 24; +const PANEL_MODE_WIDTH = 400; + +/** + * Wraps a chat provider in a React component so that ErrorBoundary can catch + * synchronous throws from the provider function itself. Calling `provider()` + * inline (e.g. `{activeChat.panel()}`) would throw outside React's render + * boundary and crash the host. + */ +const ChatRenderer = ({ provider }: { provider: () => ReactElement }) => + provider(); + +const ChatMount = () => { + const theme = useTheme(); + // Notify at most once per registration; a crash can re-render and would + // otherwise re-toast, while a replacement (new registrationId) deserves a + // fresh notification if it crashes too. + const crashNotifiedFor = useRef(null); + + // The active chat, the open state, and the display mode are read from one + // immutable registry snapshot so a render never mixes state from two + // different store versions (the tearing useSyncExternalStore prevents). + const { + open: panelOpen, + mode, + active, + } = useSyncExternalStore(subscribeToChatState, getChatSnapshot); + + if (!active) { + return null; + } + + const { registrationId } = active; + + const onProviderError = (error: Error) => { + // Fault isolation: contain the crash, log it, surface a one-time + // notification, and leave the slot empty rather than parking a + // persistent error card. + logging.error('[chat] provider crashed', error); + if (crashNotifiedFor.current !== registrationId) { + crashNotifiedFor.current = registrationId; + store.dispatch(addDangerToast(t('The chat failed to load.'))); + } + }; + + if (mode === 'panel') { + // Panel mode hides the trigger and docks the panel to the right edge. + // Interim approximation of the "layout slot between header and footer" + // from the chat API contract — the dock overlays the page until the host + // grows a real layout slot and resizer chrome. + if (!panelOpen) { + return null; + } + return ( +
+ + + +
+ ); + } + + return ( +
+ {/* + Each provider gets its own boundary so a crashing panel cannot take + the trigger down with it (the trigger is the user's only way back). + Keyed by registrationId: Superset's ErrorBoundary latches its error + state, so a takeover, fallback, or same-id re-registration must + remount the boundary to recover. + */} + {panelOpen && ( + + + + )} + + + +
+ ); +}; + +export default ChatMount; diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts new file mode 100644 index 000000000000..53765d6df94d --- /dev/null +++ b/superset-frontend/src/core/chat/index.test.ts @@ -0,0 +1,327 @@ +/** + * 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 { createElement } from 'react'; +import { chat, getActiveChat, getChatSnapshot } from './index'; + +const disposables: Array<{ dispose: () => void }> = []; + +const trigger = () => createElement('button', null, 'Bubble'); +const panel = () => createElement('div', null, 'Panel'); + +afterEach(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + // Reset host-owned state shared across tests in this module. + chat.close(); + chat.setDisplayMode('floating'); +}); + +test('getChat returns undefined when no chat is registered', () => { + expect(chat.getChat()).toBeUndefined(); + expect(getActiveChat()).toBeUndefined(); +}); + +test('registerChat resolves the registered chat with its providers', () => { + const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; + disposables.push(chat.registerChat(descriptor, trigger, panel)); + + expect(chat.getChat()).toEqual(descriptor); + expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel }); +}); + +test('getChat returns a copy that cannot mutate the registry', () => { + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel), + ); + + const copy = chat.getChat(); + copy!.name = 'Hijacked'; + + expect(chat.getChat()?.name).toBe('Acme Chat'); +}); + +test('the last-registered chat wins when multiple are installed', () => { + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + + expect(chat.getChat()?.id).toBe('second.chat'); +}); + +test('disposing the active chat falls back to the previous registration', () => { + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + ); + const second = chat.registerChat( + { id: 'second.chat', name: 'Second' }, + trigger, + panel, + ); + + expect(chat.getChat()?.id).toBe('second.chat'); + + second.dispose(); + + expect(chat.getChat()?.id).toBe('first.chat'); +}); + +test('re-registering an id replaces the previous registration', () => { + const stalePanel = () => createElement('div', null, 'Stale'); + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel), + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + + expect(chat.getChat()?.name).toBe('Acme v2'); + expect(getActiveChat()?.panel).toBe(panel); +}); + +test('each registration gets a distinct registrationId, including same-id replacements', () => { + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + const first = getActiveChat()?.registrationId; + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + const second = getActiveChat()?.registrationId; + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).not.toBe(first); +}); + +test('disposing a registration twice unregisters only once', () => { + const unregistered = jest.fn(); + disposables.push(chat.onDidUnregisterChat(unregistered)); + + const registration = chat.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + registration.dispose(); + registration.dispose(); + + expect(unregistered).toHaveBeenCalledTimes(1); + expect(chat.getChat()).toBeUndefined(); +}); + +test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => { + const registered = jest.fn(); + const unregistered = jest.fn(); + disposables.push( + chat.onDidRegisterChat(registered), + chat.onDidUnregisterChat(unregistered), + ); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + const registration = chat.registerChat(descriptor, trigger, panel); + + expect(registered).toHaveBeenCalledWith(descriptor); + expect(unregistered).not.toHaveBeenCalled(); + + registration.dispose(); + + expect(unregistered).toHaveBeenCalledWith(descriptor); +}); + +test('a disposed event subscription stops receiving notifications', () => { + const registered = jest.fn(); + const subscription = chat.onDidRegisterChat(registered); + subscription.dispose(); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + + expect(registered).not.toHaveBeenCalled(); +}); + +test('open and close toggle the panel and fire once', () => { + const opened = jest.fn(); + const closed = jest.fn(); + disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed)); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + disposables.push(chat.registerChat(descriptor, trigger, panel)); + + expect(chat.isOpen()).toBe(false); + + chat.open(); + // Opening an already-open panel is a no-op and must not re-fire. + chat.open(); + + expect(chat.isOpen()).toBe(true); + expect(opened).toHaveBeenCalledTimes(1); + + chat.close(); + chat.close(); + + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('open is a no-op while no chat is registered', () => { + const opened = jest.fn(); + disposables.push(chat.onDidOpen(opened)); + + chat.open(); + + expect(chat.isOpen()).toBe(false); + expect(opened).not.toHaveBeenCalled(); + + // A registration arriving later therefore starts closed. + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + expect(chat.isOpen()).toBe(false); +}); + +test('a takeover by a different id closes the displaced chat panel', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + const first = { id: 'first.chat', name: 'First' }; + disposables.push(chat.registerChat(first, trigger, panel)); + chat.open(); + + disposables.push( + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + + // The incoming chat must not mount into an open state it never requested. + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('a same-id replacement keeps the open state', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + chat.open(); + + // Upgrade in place: same id, new providers. + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + + expect(chat.isOpen()).toBe(true); + expect(closed).not.toHaveBeenCalled(); +}); + +test('disposing the active chat while open closes it; the fallback starts closed', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + ); + const second = { id: 'second.chat', name: 'Second' }; + const registration = chat.registerChat(second, trigger, panel); + chat.open(); + + registration.dispose(); + + expect(chat.getChat()?.id).toBe('first.chat'); + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('disposing an inactive registration leaves the open state untouched', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + const inactive = chat.registerChat( + { id: 'first.chat', name: 'First' }, + trigger, + panel, + ); + disposables.push( + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + chat.open(); + + inactive.dispose(); + + expect(chat.isOpen()).toBe(true); + expect(closed).not.toHaveBeenCalled(); +}); + +test('disposing the last chat while open resets the open state', () => { + const registration = chat.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + chat.open(); + expect(chat.isOpen()).toBe(true); + + registration.dispose(); + + expect(chat.isOpen()).toBe(false); + + // A registration arriving much later must not inherit a stale open state. + disposables.push( + chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel), + ); + expect(chat.isOpen()).toBe(false); +}); + +test('mode defaults to floating and setDisplayMode fires only on change', () => { + const modeChanged = jest.fn(); + disposables.push(chat.onDidChangeDisplayMode(modeChanged)); + + expect(chat.getDisplayMode()).toBe('floating'); + + // Setting the current mode is a no-op. + chat.setDisplayMode('floating'); + expect(modeChanged).not.toHaveBeenCalled(); + + chat.setDisplayMode('panel'); + expect(chat.getDisplayMode()).toBe('panel'); + expect(modeChanged).toHaveBeenCalledWith('panel'); +}); + +test('the snapshot is immutable per version and consistent with the registry', () => { + const before = getChatSnapshot(); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + chat.open(); + + const after = getChatSnapshot(); + // Unchanged references for old snapshots; a new object per change. + expect(after).not.toBe(before); + expect(before.active).toBeUndefined(); + expect(after).toMatchObject({ + open: true, + mode: 'floating', + active: getActiveChat(), + }); + expect(after.version).toBeGreaterThan(before.version); + // Stable reference between changes. + expect(getChatSnapshot()).toBe(after); +}); diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts new file mode 100644 index 000000000000..b98a511286b2 --- /dev/null +++ b/superset-frontend/src/core/chat/index.ts @@ -0,0 +1,240 @@ +/** + * 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 implementation of the `chat` contribution type. + * + * Chat is a dedicated contribution type, not a view: extensions register via + * the public `chat.registerChat()` and the host owns mounting, open/close + * state, and the display mode. Multiple chat extensions may register, but the + * host applies singleton resolution — the most-recently-registered chat is + * active; disposing it falls back to the previous one. + * + * Open-state policy across active-chat transitions: when the active chat's + * identity changes — a takeover by a different id, disposal falling back to a + * different id, or disposal of the last chat — the panel is closed (firing + * `onDidClose`) so the incoming chat never mounts into an open state it did + * not request. A same-id re-registration is an upgrade in place and keeps the + * open state. + * + * The public namespace (`chat`) is exposed to extensions on + * `window.superset`; the other exports are host-internal accessors for + * ChatMount and are NOT part of the public `@apache-superset/core` API. + */ + +import { ReactElement } from 'react'; +import type { chat as chatApi } from '@apache-superset/core'; +import { Disposable } from '../models'; +import { createValueEventEmitter, createEventEmitter } from '../utils'; + +type Chat = chatApi.Chat; +type DisplayMode = chatApi.DisplayMode; + +/** A registered chat: its descriptor plus the host-mountable providers. */ +export interface RegisteredChat { + /** The chat descriptor passed to `registerChat`. */ + chat: Chat; + /** Renders the collapsed bubble. Hidden by the host in panel mode. */ + trigger: () => ReactElement; + /** Renders the chat panel, mounted per the current {@link DisplayMode}. */ + panel: () => ReactElement; + /** + * Unique per registration (a same-id re-registration gets a new one). The + * host UI keys mounts and fault containment on it, so a replacement resets + * crashed error boundaries instead of inheriting their latched state. + */ + registrationId: number; +} + +/** + * Immutable snapshot of the whole chat state, rebuilt on every change. + * Returned by reference from `getChatSnapshot` so `useSyncExternalStore` + * consumers read registrations, open state, and mode from one consistent + * object instead of tearing across separate live reads. + */ +export interface ChatSnapshot { + /** Monotonic change counter, useful as a memo/effect dependency. */ + version: number; + /** Whether the active chat's panel is open. */ + open: boolean; + /** The current display mode. */ + mode: DisplayMode; + /** The active registration, or undefined when none is registered. */ + active: RegisteredChat | undefined; +} + +/** Registration order is the singleton-resolution order: last entry wins. */ +const registrations: RegisteredChat[] = []; + +let panelOpen = false; +let nextRegistrationId = 1; + +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); +const openEmitter = createEventEmitter(); +const closeEmitter = createEventEmitter(); +const resizePanelEmitter = createEventEmitter<{ width: number }>(); +const modeEmitter = createValueEventEmitter('floating'); + +/** + * Host-internal: resolves the active chat with its providers. + * The most-recently-registered chat wins; when it is disposed the previous + * registration takes over the slot again. + */ +export const getActiveChat = (): RegisteredChat | undefined => + registrations[registrations.length - 1]; + +let snapshot: ChatSnapshot = { + version: 0, + open: false, + mode: modeEmitter.getCurrent(), + active: undefined, +}; + +const stateSubscribers = new Set<() => void>(); + +const notifyState = () => { + snapshot = { + version: snapshot.version + 1, + open: panelOpen, + mode: modeEmitter.getCurrent(), + active: getActiveChat(), + }; + stateSubscribers.forEach(fn => fn()); +}; + +export const subscribeToChatState = (listener: () => void): (() => void) => { + stateSubscribers.add(listener); + return () => { + stateSubscribers.delete(listener); + }; +}; + +export const getChatSnapshot = (): ChatSnapshot => snapshot; + +/** Closes the panel and fires `onDidClose`. */ +const closePanel = () => { + panelOpen = false; + closeEmitter.fire(); +}; + +const registerChat: typeof chatApi.registerChat = ( + chat: Chat, + trigger: () => ReactElement, + panel: () => ReactElement, +): Disposable => { + const previousActive = getActiveChat(); + + // Re-registering an id replaces the previous entry and moves it to the + // most-recent position, mirroring the view registry's same-id semantics. + const existingIndex = registrations.findIndex(r => r.chat.id === chat.id); + if (existingIndex !== -1) { + registrations.splice(existingIndex, 1); + } + + const entry: RegisteredChat = { + chat, + trigger, + panel, + registrationId: nextRegistrationId, + }; + nextRegistrationId += 1; + registrations.push(entry); + registerEmitter.fire(chat); + + // A takeover by a different id closes the displaced chat's panel so the + // incoming chat never mounts already-open; a same-id replacement is an + // upgrade in place and keeps the open state. + if (panelOpen && previousActive && previousActive.chat.id !== chat.id) { + closePanel(); + } + notifyState(); + + return new Disposable(() => { + const index = registrations.indexOf(entry); + if (index === -1) { + // Already removed — replaced by a same-id registration or disposed twice. + return; + } + const wasActive = getActiveChat() === entry; + registrations.splice(index, 1); + unregisterEmitter.fire(chat); + // Disposing the active chat closes its panel; the fallback chat (if any) + // starts closed. Disposing an inactive registration leaves the open + // state of the active chat untouched. + if (panelOpen && wasActive) { + closePanel(); + } + notifyState(); + }); +}; + +const getChat: typeof chatApi.getChat = (): Chat | undefined => { + const active = getActiveChat(); + // Copy so extensions cannot mutate another extension's descriptor. + return active ? { ...active.chat } : undefined; +}; + +const open: typeof chatApi.open = (): void => { + const active = getActiveChat(); + // Open state only exists while a chat is registered; opening an empty slot + // would otherwise leak `open` into a future, unrelated registration. + if (panelOpen || !active) return; + panelOpen = true; + openEmitter.fire(); + notifyState(); +}; + +const close: typeof chatApi.close = (): void => { + const active = getActiveChat(); + if (!panelOpen || !active) return; + closePanel(); + notifyState(); +}; + +const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen; + +const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode => + modeEmitter.getCurrent(); + +const setDisplayMode: typeof chatApi.setDisplayMode = ( + displayMode: DisplayMode, +): void => { + if (displayMode === modeEmitter.getCurrent()) return; + modeEmitter.fire(displayMode); + notifyState(); +}; + +export const chat: typeof chatApi = { + registerChat, + getChat, + onDidRegisterChat: registerEmitter.subscribe, + onDidUnregisterChat: unregisterEmitter.subscribe, + open, + close, + isOpen, + onDidOpen: openEmitter.subscribe, + onDidClose: closeEmitter.subscribe, + getDisplayMode, + setDisplayMode, + onDidChangeDisplayMode: modeEmitter.subscribe, + // The host fires this from its panel resizer; until that chrome exists the + // event is exposed but never fires. + onDidResizePanel: resizePanelEmitter.subscribe, +}; diff --git a/superset-frontend/src/core/editors/EditorProviders.test.ts b/superset-frontend/src/core/editors/EditorProviders.test.ts index c3d0580ef187..9f4ac0b2be2e 100644 --- a/superset-frontend/src/core/editors/EditorProviders.test.ts +++ b/superset-frontend/src/core/editors/EditorProviders.test.ts @@ -254,33 +254,6 @@ test('event listeners can be disposed', () => { expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call }); -test('handles errors in event listeners gracefully', () => { - const manager = EditorProviders.getInstance(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const errorListener = jest.fn(() => { - throw new Error('Listener error'); - }); - const successListener = jest.fn(); - - manager.onDidRegister(errorListener); - manager.onDidRegister(successListener); - - manager.registerProvider(createMockEditor(), createMockEditorComponent()); - - // Both listeners should have been called - expect(errorListener).toHaveBeenCalledTimes(1); - expect(successListener).toHaveBeenCalledTimes(1); - - // Error should have been logged - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error in event listener:', - expect.any(Error), - ); - - consoleErrorSpy.mockRestore(); -}); - test('reset clears all providers and language mappings', () => { const manager = EditorProviders.getInstance(); diff --git a/superset-frontend/src/core/editors/EditorProviders.ts b/superset-frontend/src/core/editors/EditorProviders.ts index 2eb57c44054b..2ae7cb3d4cb1 100644 --- a/superset-frontend/src/core/editors/EditorProviders.ts +++ b/superset-frontend/src/core/editors/EditorProviders.ts @@ -19,6 +19,7 @@ import type { editors } from '@apache-superset/core'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type EditorLanguage = editors.EditorLanguage; type EditorProvider = editors.EditorProvider; @@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent; type EditorRegisteredEvent = editors.EditorRegisteredEvent; type EditorUnregisteredEvent = editors.EditorUnregisteredEvent; -/** - * Listener function type for events. - */ type Listener = (e: T) => void; -/** - * Simple event emitter for editor provider lifecycle events. - */ -class EventEmitter { - private listeners: Set> = new Set(); - - /** - * Subscribe to this event. - * @param listener The listener function to call when the event is fired. - * @returns A Disposable to unsubscribe from the event. - */ - subscribe(listener: Listener): Disposable { - this.listeners.add(listener); - return new Disposable(() => { - this.listeners.delete(listener); - }); - } - - /** - * Fire the event with the given data. - * @param data The event data to pass to listeners. - */ - fire(data: T): void { - this.listeners.forEach(listener => { - try { - listener(data); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error in event listener:', error); - } - }); - } -} - /** * Singleton manager for editor providers. * Handles registration, resolution, and lifecycle of custom editor implementations. @@ -83,15 +47,9 @@ class EditorProviders { */ private languageToProvider: Map = new Map(); - /** - * Event emitter for provider registration events. - */ - private registerEmitter = new EventEmitter(); + private registerEmitter = createEventEmitter(); - /** - * Event emitter for provider unregistration events. - */ - private unregisterEmitter = new EventEmitter(); + private unregisterEmitter = createEventEmitter(); private syncListeners: Set<() => void> = new Set(); diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index 6a106ebe87ae..ce5b7f4d5052 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -27,11 +27,13 @@ export const core: typeof coreType = { }; export * from './authentication'; +export * from './chat'; export * from './commands'; export * from './editors'; export * from './extensions'; export * from './menus'; export * from './models'; +export * from './navigation'; export * from './sqlLab'; export * from './utils'; export * from './views'; diff --git a/superset-frontend/src/core/menus/index.ts b/superset-frontend/src/core/menus/index.ts index be2066a178b9..b11a0ff44b72 100644 --- a/superset-frontend/src/core/menus/index.ts +++ b/superset-frontend/src/core/menus/index.ts @@ -27,6 +27,7 @@ import { useSyncExternalStore } from 'react'; import type { menus as menusApi } from '@apache-superset/core'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type MenuItem = menusApi.MenuItem; type Menu = menusApi.Menu; @@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => { return () => syncListeners.delete(listener); }; -const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>(); -const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>(); +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); const menuCache = new Map(); const notifyRegister = (event: MenuItemRegisteredEvent) => { menuCache.clear(); syncListeners.forEach(l => l()); - registerListeners.forEach(l => l(event)); + registerEmitter.fire(event); }; const notifyUnregister = (event: MenuItemUnregisteredEvent) => { menuCache.clear(); syncListeners.forEach(l => l()); - unregisterListeners.forEach(l => l(event)); + unregisterEmitter.fire(event); }; const registerMenuItem: typeof menusApi.registerMenuItem = ( @@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined => export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = ( listener: (e: MenuItemRegisteredEvent) => void, -): Disposable => { - registerListeners.add(listener); - return new Disposable(() => registerListeners.delete(listener)); -}; +): Disposable => registerEmitter.subscribe(listener); export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem = - (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => { - unregisterListeners.add(listener); - return new Disposable(() => unregisterListeners.delete(listener)); - }; + (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => + unregisterEmitter.subscribe(listener); export const menus: typeof menusApi = { registerMenuItem, diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts new file mode 100644 index 000000000000..93fc2e03a96b --- /dev/null +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -0,0 +1,124 @@ +/** + * 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. + */ + +// Reset module state between tests so currentPage is re-initialized. +beforeEach(() => { + jest.resetModules(); + Object.defineProperty(window, 'location', { + writable: true, + value: { pathname: '/' }, + }); +}); + +async function importNavigation() { + const mod = await import('./index'); + return mod; +} + +test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => { + const { navigation, notifyPageChange } = await importNavigation(); + // The default pathname ('/') is not enumerated and falls back to home. + expect(navigation.getPage()).toBe('home'); + notifyPageChange('/superset/welcome/'); + expect(navigation.getPage()).toBe('home'); +}); + +test('getPage derives the page from window.location.pathname', async () => { + window.location.pathname = '/superset/dashboard/42/'; + const { navigation } = await importNavigation(); + expect(navigation.getPage()).toBe('dashboard'); +}); + +test('notifyPageChange updates the current page type', async () => { + const { navigation, notifyPageChange } = await importNavigation(); + notifyPageChange('/explore/?form_data={}'); + expect(navigation.getPage()).toBe('explore'); +}); + +test('notifyPageChange fires listeners on page type change', async () => { + const { navigation, notifyPageChange } = await importNavigation(); + const listener = jest.fn(); + const disposable = navigation.onDidChangePage(listener); + + notifyPageChange('/superset/dashboard/1/'); + expect(listener).toHaveBeenCalledWith('dashboard'); + + disposable.dispose(); +}); + +test('notifyPageChange does not fire listeners when page type is unchanged', async () => { + window.location.pathname = '/superset/dashboard/1/'; + const { navigation, notifyPageChange } = await importNavigation(); + const listener = jest.fn(); + navigation.onDidChangePage(listener); + + notifyPageChange('/superset/dashboard/2/'); + expect(listener).not.toHaveBeenCalled(); +}); + +test('onDidChangePage listener is removed after dispose', async () => { + const { navigation, notifyPageChange } = await importNavigation(); + const listener = jest.fn(); + const disposable = navigation.onDidChangePage(listener); + + disposable.dispose(); + notifyPageChange('/superset/dashboard/1/'); + expect(listener).not.toHaveBeenCalled(); +}); + +test('sqllab path is matched with and without trailing slash', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/sqllab'); + expect(navigation.getPage()).toBe('sqllab'); + notifyPageChange('/explore/'); + notifyPageChange('/sqllab/'); + expect(navigation.getPage()).toBe('sqllab'); +}); + +test('chart and dashboard list pages get their own page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/chart/list/'); + expect(navigation.getPage()).toBe('chart_list'); + notifyPageChange('/dashboard/list/'); + expect(navigation.getPage()).toBe('dashboard_list'); +}); + +test('dataset list and single-dataset pages get distinct page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/tablemodelview/list/'); + expect(navigation.getPage()).toBe('dataset_list'); + notifyPageChange('/dataset/42'); + expect(navigation.getPage()).toBe('dataset'); +}); + +test('sqllab editor, query history, and saved queries get distinct page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/sqllab/'); + expect(navigation.getPage()).toBe('sqllab'); + notifyPageChange('/sqllab/history/'); + expect(navigation.getPage()).toBe('query_history'); + notifyPageChange('/savedqueryview/list/'); + expect(navigation.getPage()).toBe('saved_queries'); +}); + +test('chart/add resolves to explore, not chart_list', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/chart/add'); + expect(navigation.getPage()).toBe('explore'); +}); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts new file mode 100644 index 000000000000..f670a1a45f04 --- /dev/null +++ b/superset-frontend/src/core/navigation/index.ts @@ -0,0 +1,79 @@ +/** + * 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. + */ + +/** + * Host-internal implementation of the `navigation` namespace. + * + * Backed by browser location — no Redux dependency. + * The app shell calls `notifyPageChange(pathname)` whenever the route changes. + */ + +import type { navigation as navigationApi } from '@apache-superset/core'; +import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; + +type Page = navigationApi.Page; + +const pageChangeEmitter = createEventEmitter(); + +function derivePage(pathname: string): Page { + if (pathname.startsWith('/superset/dashboard/')) return 'dashboard'; + if (pathname.startsWith('/dashboard/list')) return 'dashboard_list'; + if (pathname.startsWith('/explore/')) return 'explore'; + if (pathname.startsWith('/superset/explore/')) return 'explore'; + if (pathname.startsWith('/chart/add')) return 'explore'; + if (pathname.startsWith('/chart/list')) return 'chart_list'; + if (pathname.startsWith('/sqllab/history')) return 'query_history'; + if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries'; + if (pathname === '/sqllab' || pathname.startsWith('/sqllab/')) + return 'sqllab'; + if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list'; + if (pathname.startsWith('/dataset/')) return 'dataset'; + // The welcome page and any route not explicitly enumerated fall back to home. + return 'home'; +} + +let currentPage: Page | undefined; + +function getOrInitPage(): Page { + if (currentPage === undefined) { + currentPage = derivePage(window.location.pathname); + } + return currentPage; +} + +/** Called by ExtensionsStartup whenever the React Router location changes. */ +export const notifyPageChange = (pathname: string): void => { + const next = derivePage(pathname); + if (next === getOrInitPage()) return; + currentPage = next; + pageChangeEmitter.fire(next); +}; + +const getPage: typeof navigationApi.getPage = () => getOrInitPage(); + +const onDidChangePage: typeof navigationApi.onDidChangePage = ( + listener: (page: Page) => void, + thisArgs?: any, +): Disposable => pageChangeEmitter.subscribe(listener, thisArgs); + +export const navigation: typeof navigationApi = { + getPage, + onDidChangePage, +}; diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index 1e4dded93c35..00c675523f1a 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -21,6 +21,57 @@ import { AnyAction } from 'redux'; import { listenerMiddleware, RootState, store } from 'src/views/store'; import { AnyListenerPredicate } from '@reduxjs/toolkit'; +type Listener = (e: T) => unknown; + +/** A stateless event emitter exposing a VS Code-style `event` subscriber. */ +export interface EventEmitter { + /** Notifies every current subscriber with `value`. */ + fire(value: T): void; + /** Registers a listener; returns a Disposable that removes it. */ + subscribe: core.Event; +} + +/** An event emitter that also retains the last fired value. */ +export interface ValueEventEmitter extends EventEmitter { + /** Returns the value last passed to {@link fire} (or the initial value). */ + getCurrent(): T; +} + +/** + * Creates a stateless event emitter. Listeners registered via `event` receive + * every subsequent `fire`; a returned Disposable removes the listener. + */ +export function createEventEmitter(): EventEmitter { + const listeners = new Set>(); + const subscribe: core.Event = (listener, thisArgs) => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return { dispose: () => listeners.delete(bound) }; + }; + return { + fire: value => listeners.forEach(fn => fn(value)), + subscribe, + }; +} + +/** + * Creates a value event emitter seeded with `initial`. Behaves like + * {@link createEventEmitter} but also tracks the last fired value, readable + * via `getCurrent` — useful for state that is both observed and queried. + */ +export function createValueEventEmitter(initial: T): ValueEventEmitter { + const { fire, subscribe } = createEventEmitter(); + let current = initial; + return { + fire: value => { + current = value; + fire(value); + }, + subscribe, + getCurrent: () => current, + }; +} + export function createActionListener( predicate: AnyListenerPredicate, listener: (v: V) => void, diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 3e8f775993e6..a28cfc4c37bf 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core'; import { ErrorBoundary } from 'src/components/ErrorBoundary'; import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type View = viewsApi.View; type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent; @@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => { return () => syncListeners.delete(listener); }; -const registerListeners = new Set<(e: ViewRegisteredEvent) => void>(); -const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>(); +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); const viewsCache = new Map(); const notifyRegister = (event: ViewRegisteredEvent) => { viewsCache.clear(); syncListeners.forEach(l => l()); - registerListeners.forEach(l => l(event)); + registerEmitter.fire(event); }; const notifyUnregister = (event: ViewUnregisteredEvent) => { viewsCache.clear(); syncListeners.forEach(l => l()); - unregisterListeners.forEach(l => l(event)); + unregisterEmitter.fire(event); }; const registerView: typeof viewsApi.registerView = ( @@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined => export const onDidRegisterView: typeof viewsApi.onDidRegisterView = ( listener: (e: ViewRegisteredEvent) => void, -): Disposable => { - registerListeners.add(listener); - return new Disposable(() => registerListeners.delete(listener)); -}; +): Disposable => registerEmitter.subscribe(listener); export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = ( listener: (e: ViewUnregisteredEvent) => void, -): Disposable => { - unregisterListeners.add(listener); - return new Disposable(() => unregisterListeners.delete(listener)); -}; +): Disposable => unregisterEmitter.subscribe(listener); export const views: typeof viewsApi = { registerView, diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index 1d2493671ad1..d2debd6d69c1 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial = {}): Extension { version: '1.0.0', dependencies: [], remoteEntry: '', - extensionDependencies: [], ...overrides, }; } diff --git a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx index 4a8d92c8d8a7..59ec6100bfe9 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx @@ -72,6 +72,7 @@ afterEach(() => { test('renders without crashing', () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => { // Verify the global superset object is set up expect((window as any).superset).toBeDefined(); expect((window as any).superset.authentication).toBeDefined(); + expect((window as any).superset.chat).toBeDefined(); expect((window as any).superset.core).toBeDefined(); expect((window as any).superset.commands).toBeDefined(); expect((window as any).superset.extensions).toBeDefined(); @@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => { test('does not set up global superset object when user is not logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialStateNoUser, }); @@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => { test('does not initialize ExtensionsLoader when user is not logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialStateNoUser, }); @@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => { const { rerender } = render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai , { useRedux: true, + useRouter: true, initialState: mockInitialState, }, ); diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index fba4e2318a19..1e972bdc6535 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -16,58 +16,78 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { logging } from '@apache-superset/core/utils'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-syntax import * as supersetCore from '@apache-superset/core'; -import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { authentication, + chat, core, commands, editors, extensions, menus, + navigation, sqlLab, views, } from 'src/core'; +import { notifyPageChange } from 'src/core/navigation'; import { useSelector } from 'react-redux'; import { RootState } from 'src/views/store'; import ExtensionsLoader from './ExtensionsLoader'; - -declare global { - interface Window { - superset: { - authentication: typeof authentication; - core: typeof core; - commands: typeof commands; - editors: typeof editors; - extensions: typeof extensions; - menus: typeof menus; - sqlLab: typeof sqlLab; - views: typeof views; - }; - } -} +import 'src/extensions/Namespaces'; const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, }) => { + const location = useLocation(); + const prevPathname = useRef(null); + const userId = useSelector( ({ user }) => user.userId, ); + // Notify the navigation namespace on every route change. useEffect(() => { - if (userId == null) return; + if (prevPathname.current !== location.pathname) { + prevPathname.current = location.pathname; + notifyPageChange(location.pathname); + } + }, [location.pathname]); - // Provide the implementations for @apache-superset/core + // Log unhandled rejections that may originate from extension code. + // Registered once for the lifetime of the app; does not suppress the + // browser's default error surfacing so host error reporting is unaffected. + useEffect(() => { + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + logging.error('[extensions] Unhandled rejection:', event.reason); + }; + window.addEventListener('unhandledrejection', handleUnhandledRejection); + return () => { + window.removeEventListener( + 'unhandledrejection', + handleUnhandledRejection, + ); + }; + }, []); + + useEffect(() => { + // Provide the implementations for @apache-superset/core. + // Namespaces are listed explicitly — do not spread the core package here, + // as that would leak un-contracted symbols onto window.superset. window.superset = { ...supersetCore, authentication, + chat, core, commands, editors, extensions, menus, + navigation, sqlLab, views, }; diff --git a/superset-frontend/src/extensions/Namespaces.ts b/superset-frontend/src/extensions/Namespaces.ts new file mode 100644 index 000000000000..0e7f6a3063b2 --- /dev/null +++ b/superset-frontend/src/extensions/Namespaces.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * Global `window.superset` type augmentation. + * + * Lives in its own module (rather than inline in ExtensionsStartup) so every + * file that reads or writes `window.superset` — notably ExtensionsLoader — + * sees the type regardless of how files are batched during compilation. Both + * the startup component and the loader import this module for its side effect. + */ + +import type { + authentication, + chat, + commands, + core, + editors, + extensions, + menus, + navigation, + sqlLab, + views, +} from 'src/core'; + +/** The host namespaces exposed to extensions on `window.superset`. */ +export interface Namespaces { + authentication: typeof authentication; + core: typeof core; + chat: typeof chat; + commands: typeof commands; + editors: typeof editors; + extensions: typeof extensions; + menus: typeof menus; + navigation: typeof navigation; + sqlLab: typeof sqlLab; + views: typeof views; +} + +declare global { + interface Window { + superset: Namespaces; + } +} diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 4f30a552e94e..6a5c08672ee5 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; import { logEvent } from 'src/logger/actions'; import { store } from 'src/views/store'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import ExtensionsStartup from 'src/extensions/ExtensionsStartup'; +import ChatMount from 'src/components/ChatMount'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -112,6 +114,13 @@ const App = () => ( ))} + {/* + The singleton chat slot. Rendered as a sibling of the route + Switch — inside ExtensionsStartup so chat extensions have been + loaded and registered, but outside the Switch so the chat persists + across route changes. + */} + {isFeatureEnabled(FeatureFlag.EnableExtensions) && } diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index 4455560696cd..fc5b6250bb6c 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -238,6 +238,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]: manifest = extension.manifest extension_data: dict[str, Any] = { "id": manifest.id, + "publisher": manifest.publisher, "name": extension.name, "version": extension.version, "description": manifest.description or "",