diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index e765c5009c40..f339fc01949c 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application. -```tsx -import React from 'react'; +```typescript import { views } from '@apache-superset/core'; import MyPanel from './MyPanel'; views.registerView( { id: 'my-extension.main', name: 'My Panel Name' }, 'sqllab.panels', - () => , + MyPanel, ); ``` @@ -112,6 +111,24 @@ editors.registerEditor( See [Editors Extension Point](./extension-points/editors.md) for implementation details. +### Chat + +Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads. + +```tsx +import { chat } from '@apache-superset/core'; +import ChatTrigger from './ChatTrigger'; +import ChatPanel from './ChatPanel'; + +chat.registerChat( + { id: 'my-org.my-chat', name: 'My Chat' }, + ChatTrigger, + ChatPanel, +); +``` + +See [Chat](./extension-points/chat.md) for implementation details. + ## Backend Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file. diff --git a/docs/developer_docs/extensions/extension-points/chat.md b/docs/developer_docs/extensions/extension-points/chat.md new file mode 100644 index 000000000000..42ce77a6eec1 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/chat.md @@ -0,0 +1,140 @@ +--- +title: Chat +sidebar_position: 3 +--- + + + +# Chat Contributions + +Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components. + +## Overview + +A chat registration consists of two React components: + +| Component | Role | +|-----------|------| +| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. | +| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. | + +## Display Modes + +The host supports two display modes, switchable by the user or the extension at runtime: + +| Mode | Behavior | +|------|----------| +| `floating` | Panel floats above page content, anchored to the bottom-right corner. | +| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. | + +The user's last selected mode and open/closed state are persisted across page reloads. + +## Registering a Chat + +Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory: + +```tsx +import { chat } from '@apache-superset/core'; +import ChatTrigger from './ChatTrigger'; +import ChatPanel from './ChatPanel'; + +chat.registerChat( + { id: 'my-org.my-chat', name: 'My Chat' }, + ChatTrigger, + ChatPanel, +); +``` + +Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged. + +## Opening and Closing the Chat + +The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility: + +```tsx +import { chat } from '@apache-superset/core'; + +export default function ChatTrigger() { + return ( + + ); +} +``` + +You can also subscribe to open/close events from any component: + +```tsx +useEffect(() => { + const { dispose } = chat.onDidOpen(() => console.log('chat opened')); + return dispose; +}, []); +``` + +## Changing the Display Mode + +Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user): + +```tsx +import { useState, useEffect } from 'react'; +import { chat } from '@apache-superset/core'; + +export default function ChatPanel() { + const [mode, setMode] = useState(chat.getDisplayMode()); + + useEffect(() => { + const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m)); + return dispose; + }, []); + + return ( +
+ + {/* message list and input */} +
+ ); +} +``` + +## Chat API Reference + +All methods are available on the `chat` namespace from `@apache-superset/core`: + +| Method / Event | Description | +|----------------|-------------| +| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. | +| `open()` | Open the chat panel. No-op if already open or no registration. | +| `close()` | Close the chat panel. | +| `isOpen()` | Returns `true` if the panel is currently open. | +| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). | +| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. | +| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. | +| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. | +| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. | +| `onDidRegisterChat(listener)` | Subscribe to registration events. | +| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. | + +## Next Steps + +- **[Contribution Types](../contribution-types.md)** — Explore other contribution types +- **[Development](../development.md)** — Set up your development environment diff --git a/docs/developer_docs/sidebars.js b/docs/developer_docs/sidebars.js index 7926d80cf615..fe03dac7b8f2 100644 --- a/docs/developer_docs/sidebars.js +++ b/docs/developer_docs/sidebars.js @@ -47,6 +47,8 @@ module.exports = { collapsed: true, items: [ 'extensions/extension-points/sqllab', + 'extensions/extension-points/editors', + 'extensions/extension-points/chat', ], }, 'extensions/development', diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9979d870021f..032ba463f81b 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -26127,6 +26127,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", @@ -43249,6 +43264,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 0747ebe9ba37..c91754ab260d 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..ad91761f150d --- /dev/null +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -0,0 +1,156 @@ +/** + * 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: 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' }, + * AcmeTrigger, + * AcmePanel, + * ); + * ``` + */ + +import { ComponentType } 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. */ + description?: string; +} + +export type DisplayMode = 'floating' | 'panel'; + +/** + * Registers a chat provider. Only one chat is active at a time; the most + * recently registered chat wins. Disposing the returned Disposable unregisters + * the chat. + * + * @param chat The chat descriptor (id, name). + * @param trigger The trigger component — the collapsed bubble entry point. + * Owns dynamic state such as unread counts. + * @param panel The panel component, rendered in either display mode. In + * 'floating' mode it appears as an overlay; in 'panel' mode it is docked + * alongside the main content. + * @returns A Disposable that unregisters the chat when disposed. + * + * @example + * ```typescript + * chat.registerChat( + * { id: 'acme.chat', name: 'Acme Chat' }, + * AcmeTrigger, + * AcmePanel, + * ); + * ``` + */ +export declare function registerChat( + chat: Chat, + trigger: ComponentType, + panel: ComponentType, +): Disposable; + +/** + * Returns the active chat descriptor, or undefined if none is registered. + */ +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. + */ +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, whether triggered by an extension + * or by the host. + */ +export declare const onDidClose: Event; + +/** + * Returns the current display mode. + */ +export declare function getDisplayMode(): DisplayMode; + +/** + * Sets the display mode. The mode is host-global and applies to whichever + * chat is active. Use {@link onDidChangeDisplayMode} to observe all changes, + * including those triggered by the host. + */ +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. Not all hosts provide + * a resizer — do not rely on this event 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/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index df5990046510..b3d57babe39e 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -30,12 +30,12 @@ * * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' }, - * () => , + * ResultStatsPanel, * ); * ``` */ -import { ReactElement } from 'react'; +import { ComponentType } from 'react'; import { Disposable, Event } from '../common'; /** @@ -58,7 +58,7 @@ export interface View { * * @param view The view descriptor (id and name). * @param location The location where this view should appear (e.g. "sqllab.panels"). - * @param provider A function that returns the React element to render. + * @param component The React component to render at that location. * @returns A Disposable that unregisters the view when disposed. * * @example @@ -66,14 +66,14 @@ export interface View { * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats' }, * 'sqllab.panels', - * () => , + * ResultStatsPanel, * ); * ``` */ export declare function registerView( view: View, location: string, - provider: () => ReactElement, + component: ComponentType, ): Disposable; /** diff --git a/superset-frontend/src/core/chat/ChatHost.test.tsx b/superset-frontend/src/core/chat/ChatHost.test.tsx new file mode 100644 index 000000000000..aa4d569c70e6 --- /dev/null +++ b/superset-frontend/src/core/chat/ChatHost.test.tsx @@ -0,0 +1,277 @@ +/** + * 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 ChatProvider from './ChatProvider'; +import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost'; + +beforeEach(() => { + ChatProvider.getInstance().reset(); +}); + +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', () => { + 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', () => { + 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', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ); + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ); + + jest.restoreAllMocks(); + 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(() => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ); + }); + + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('a takeover mounts the incoming chat closed', () => { + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ); + + render(); + act(() => chat.open()); + expect(screen.getByText('First Panel')).toBeInTheDocument(); + + act(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ); + jest.restoreAllMocks(); + }); + + // 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('ChatPanelHost renders the panel when open in panel mode', () => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ); + + render(); + + act(() => { + chat.setDisplayMode('panel'); + chat.open(); + }); + + expect(screen.getByText('Acme Panel')).toBeInTheDocument(); +}); + +test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ); + + render(); + + act(() => { + chat.setDisplayMode('panel'); + chat.open(); + }); + + // In panel mode the floating panel is suppressed (ChatPanelHost owns that slot). + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); + // The trigger stays rendered so the user can reopen after collapsing. + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); + + act(() => chat.close()); + + // Trigger remains visible even when closed — it's the user's only way back. + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('a crashing panel does not take the trigger down with it', () => { + const FailingPanel = () => { + throw new Error('panel blew up'); + }; + 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'); + }; + 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 component that throws during render', () => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => { + throw new Error('provider blew up'); + }, + () =>
Acme Panel
, + ); + + 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'); + }; + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () => , + () =>
First Panel
, + ); + + render(); + expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); + + act(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ); + jest.restoreAllMocks(); + }); + + // 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 from a crashed chat when a different id takes over', () => { + const FailingTrigger = () => { + throw new Error('broken release'); + }; + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ); + + render(); + + act(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'fixed.chat', name: 'Fixed Chat' }, + () =>
Fixed Bubble
, + () =>
Fixed Panel
, + ); + jest.restoreAllMocks(); + }); + + // Different id: boundary key changes, latch resets, fix renders. + expect(screen.getByText('Fixed Bubble')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/core/chat/ChatHost.tsx b/superset-frontend/src/core/chat/ChatHost.tsx new file mode 100644 index 000000000000..73ba519f694d --- /dev/null +++ b/superset-frontend/src/core/chat/ChatHost.tsx @@ -0,0 +1,133 @@ +/** + * 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 ComponentType, useRef } 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 { useChat } from '.'; + +const CHAT_EDGE_MARGIN = 24; + +/** + * Returns an onError handler that shows a toast on crash, once per chat id. + */ +function useCrashNotifier(chatId: string | undefined) { + const notifiedFor = useRef(undefined); + return (error: Error) => { + if (!chatId) return; + logging.error('[chat] provider crashed', error); + if (notifiedFor.current !== chatId) { + notifiedFor.current = chatId; + store.dispatch(addDangerToast(t('The chat failed to load.'))); + } + }; +} + +/** + * Wraps a component in an ErrorBoundary, keyed by chat id so the boundary + * resets when a different chat takes over. + */ +const ChatBoundary = ({ + component: Component, + onError, +}: { + component: ComponentType; + onError: (error: Error) => void; +}) => ( + + + +); + +/** + * Renders the chat panel content in panel mode. Fills its container height. + */ +export const ChatPanelHost = () => { + const { chat, panel } = useChat(); + const onError = useCrashNotifier(chat?.id); + + if (!chat || !panel) { + return null; + } + + return ( +
+ +
+ ); +}; + +/** + * Renders the chat trigger and, when the panel is open in floating mode, the + * floating panel overlay. The trigger is always visible when a chat is + * registered; the panel overlay is suppressed in panel mode. + */ +export const ChatFloatingHost = () => { + const theme = useTheme(); + const { open: panelOpen, mode, chat, trigger, panel } = useChat(); + const onError = useCrashNotifier(chat?.id); + + if (!chat || !trigger || !panel) { + return null; + } + + return ( +
+ {/* + Separate boundaries so a crashing panel cannot take the trigger down + with it — the trigger is the user's only way back. + */} + {panelOpen && mode !== 'panel' && ( + + )} + +
+ ); +}; diff --git a/superset-frontend/src/core/chat/ChatProvider.test.ts b/superset-frontend/src/core/chat/ChatProvider.test.ts new file mode 100644 index 000000000000..392f50a114f8 --- /dev/null +++ b/superset-frontend/src/core/chat/ChatProvider.test.ts @@ -0,0 +1,257 @@ +/** + * 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 ChatProvider from './ChatProvider'; + +const trigger = () => createElement('button', null, 'Bubble'); +const panel = () => createElement('div', null, 'Panel'); + +beforeEach(() => { + ChatProvider.getInstance().reset(); +}); + +test('returns the singleton instance', () => { + expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance()); +}); + +test('getChat returns undefined when no chat is registered', () => { + expect(ChatProvider.getInstance().getChat()).toBeUndefined(); +}); + +test('registerChat sets the registration and returns the descriptor copy', () => { + const provider = ChatProvider.getInstance(); + const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; + const disposable = provider.registerChat(descriptor, trigger, panel); + + expect(provider.getChat()).toEqual(descriptor); + disposable.dispose(); +}); + +test('the last-registered chat wins and logs a warning', () => { + const provider = ChatProvider.getInstance(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + + expect(provider.getChat()?.id).toBe('second.chat'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('second.chat'); + expect(warn.mock.calls[0][0]).toContain('first.chat'); + warn.mockRestore(); +}); + +test('re-registering with a different id replaces the active chat', () => { + const provider = ChatProvider.getInstance(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + expect(provider.getChat()?.id).toBe('first.chat'); + + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + expect(provider.getChat()?.id).toBe('second.chat'); + + jest.restoreAllMocks(); +}); + +test('disposing the registration clears it', () => { + const provider = ChatProvider.getInstance(); + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + + disposable.dispose(); + + expect(provider.getChat()).toBeUndefined(); +}); + +test('disposing twice fires unregister only once', () => { + const provider = ChatProvider.getInstance(); + const unregistered = jest.fn(); + provider.onDidUnregisterChat(unregistered); + + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + disposable.dispose(); + disposable.dispose(); + + expect(unregistered).toHaveBeenCalledTimes(1); +}); + +test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => { + const provider = ChatProvider.getInstance(); + const registered = jest.fn(); + const unregistered = jest.fn(); + provider.onDidRegisterChat(registered); + provider.onDidUnregisterChat(unregistered); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + const disposable = provider.registerChat(descriptor, trigger, panel); + + expect(registered).toHaveBeenCalledWith(descriptor); + expect(unregistered).not.toHaveBeenCalled(); + + disposable.dispose(); + + expect(unregistered).toHaveBeenCalledWith(descriptor); +}); + +test('open and close toggle the panel state', () => { + const provider = ChatProvider.getInstance(); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); + + provider.open(); + expect(provider.isOpen()).toBe(true); + + provider.close(); + expect(provider.isOpen()).toBe(false); +}); + +test('open fires once; duplicate open is a no-op', () => { + const provider = ChatProvider.getInstance(); + const opened = jest.fn(); + provider.onDidOpen(opened); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + provider.open(); + provider.open(); + + expect(opened).toHaveBeenCalledTimes(1); +}); + +test('close fires once; duplicate close is a no-op', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + provider.open(); + provider.close(); + provider.close(); + + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('open is a no-op when no chat is registered', () => { + const provider = ChatProvider.getInstance(); + const opened = jest.fn(); + provider.onDidOpen(opened); + + provider.open(); + + expect(provider.isOpen()).toBe(false); + expect(opened).not.toHaveBeenCalled(); +}); + +test('registering a second chat while open closes the panel', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + provider.open(); + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); + jest.restoreAllMocks(); +}); + +test('disposing the active chat while open closes the panel', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + provider.open(); + disposable.dispose(); + + expect(provider.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('a late registration does not inherit a stale open state', () => { + const provider = ChatProvider.getInstance(); + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + provider.open(); + disposable.dispose(); + + provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); +}); + +test('getDisplayMode defaults to floating', () => { + expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating'); +}); + +test('setDisplayMode updates mode and fires event only on change', () => { + const provider = ChatProvider.getInstance(); + const modeChanged = jest.fn(); + provider.onDidChangeDisplayMode(modeChanged); + + provider.setDisplayMode('floating'); + expect(modeChanged).not.toHaveBeenCalled(); + + provider.setDisplayMode('panel'); + expect(provider.getDisplayMode()).toBe('panel'); + expect(modeChanged).toHaveBeenCalledWith('panel'); +}); + +test('state reflects changes after registration and open', () => { + const provider = ChatProvider.getInstance(); + + expect(provider.getChat()).toBeUndefined(); + expect(provider.isOpen()).toBe(false); + + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + provider.open(); + + expect(provider.isOpen()).toBe(true); + expect(provider.getChat()?.id).toBe('acme.chat'); +}); + +test('reset clears all state', () => { + const provider = ChatProvider.getInstance(); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + provider.open(); + provider.setDisplayMode('panel'); + + provider.reset(); + + expect(provider.getChat()).toBeUndefined(); + expect(provider.isOpen()).toBe(false); + expect(provider.getDisplayMode()).toBe('floating'); +}); diff --git a/superset-frontend/src/core/chat/ChatProvider.ts b/superset-frontend/src/core/chat/ChatProvider.ts new file mode 100644 index 000000000000..b0d2f79af50e --- /dev/null +++ b/superset-frontend/src/core/chat/ChatProvider.ts @@ -0,0 +1,209 @@ +/** + * 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 { ComponentType } from 'react'; +import type { chat as chatApi } from '@apache-superset/core'; +import { + LocalStorageKeys, + getItem, + setItem, +} from 'src/utils/localStorageHelpers'; +import { Disposable } from '../models'; +import { createValueEventEmitter, createEventEmitter } from '../utils'; + +type Chat = chatApi.Chat; +type DisplayMode = chatApi.DisplayMode; + +/** + * Singleton manager for the chat provider. + * Handles registration, open/close state, and display mode. + */ +class ChatProvider { + private static instance: ChatProvider; + + private chat: Chat | undefined; + + private trigger: ComponentType | undefined; + + private panel: ComponentType | undefined; + + private opened: boolean; + + private stateSubscribers = new Set<() => void>(); + + private registerEmitter = createEventEmitter(); + + private unregisterEmitter = createEventEmitter(); + + private openEmitter = createEventEmitter(); + + private closeEmitter = createEventEmitter(); + + private resizePanelEmitter = createEventEmitter<{ width: number }>(); + + private modeEmitter: ReturnType>; + + private constructor() { + const persisted = getItem(LocalStorageKeys.ChatState, { + open: false, + mode: 'floating', + }); + const mode = ( + persisted.mode === 'panel' ? 'panel' : 'floating' + ) as DisplayMode; + this.opened = persisted.open === true; + this.modeEmitter = createValueEventEmitter(mode); + } + + public static getInstance(): ChatProvider { + if (!ChatProvider.instance) { + ChatProvider.instance = new ChatProvider(); + } + return ChatProvider.instance; + } + + public subscribe = (listener: () => void): (() => void) => { + this.stateSubscribers.add(listener); + return () => this.stateSubscribers.delete(listener); + }; + + private notifyState(): void { + setItem(LocalStorageKeys.ChatState, { + open: this.opened, + mode: this.modeEmitter.getCurrent(), + }); + this.stateSubscribers.forEach(fn => fn()); + } + + private closePanel(): void { + this.opened = false; + this.closeEmitter.fire(); + } + + public registerChat( + chat: Chat, + trigger: ComponentType, + panel: ComponentType, + ): Disposable { + if (this.chat) { + // eslint-disable-next-line no-console + console.warn( + `[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`, + ); + this.unregisterEmitter.fire(this.chat); + if (this.opened) this.closePanel(); + } + + this.chat = chat; + this.trigger = trigger; + this.panel = panel; + this.registerEmitter.fire(chat); + this.notifyState(); + + return new Disposable(() => { + if (this.chat !== chat) return; + this.chat = undefined; + this.trigger = undefined; + this.panel = undefined; + this.unregisterEmitter.fire(chat); + if (this.opened) this.closePanel(); + this.notifyState(); + }); + } + + public getChat(): Chat | undefined { + return this.chat; + } + + public getTrigger(): ComponentType | undefined { + return this.trigger; + } + + public getPanel(): ComponentType | undefined { + return this.panel; + } + + public open(): void { + if (this.opened || !this.chat) return; + this.opened = true; + this.openEmitter.fire(); + this.notifyState(); + } + + public close(): void { + if (!this.opened || !this.chat) return; + this.closePanel(); + this.notifyState(); + } + + public isOpen(): boolean { + return this.opened; + } + + public getDisplayMode(): DisplayMode { + return this.modeEmitter.getCurrent(); + } + + public setDisplayMode(displayMode: DisplayMode): void { + if (displayMode === this.modeEmitter.getCurrent()) return; + this.modeEmitter.fire(displayMode); + this.notifyState(); + } + + public get onDidRegisterChat() { + return this.registerEmitter.subscribe; + } + + public get onDidUnregisterChat() { + return this.unregisterEmitter.subscribe; + } + + public get onDidOpen() { + return this.openEmitter.subscribe; + } + + public get onDidClose() { + return this.closeEmitter.subscribe; + } + + public get onDidChangeDisplayMode() { + return this.modeEmitter.subscribe; + } + + public get onDidResizePanel() { + return this.resizePanelEmitter.subscribe; + } + + public reset(): void { + this.chat = undefined; + this.trigger = undefined; + this.panel = undefined; + this.opened = false; + this.registerEmitter = createEventEmitter(); + this.unregisterEmitter = createEventEmitter(); + this.openEmitter = createEventEmitter(); + this.closeEmitter = createEventEmitter(); + this.resizePanelEmitter = createEventEmitter<{ width: number }>(); + this.modeEmitter = createValueEventEmitter('floating'); + this.stateSubscribers.clear(); + setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' }); + } +} + +export default ChatProvider; 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..2fee1989135f --- /dev/null +++ b/superset-frontend/src/core/chat/index.test.ts @@ -0,0 +1,68 @@ +/** + * 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 } from './index'; +import ChatProvider from './ChatProvider'; + +const trigger = () => createElement('button', null, 'Bubble'); +const panel = () => createElement('div', null, 'Panel'); + +beforeEach(() => { + ChatProvider.getInstance().reset(); +}); + +test('getChat returns undefined when no chat is registered', () => { + expect(chat.getChat()).toBeUndefined(); +}); + +test('registerChat makes the chat retrievable via getChat', () => { + const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; + chat.registerChat(descriptor, trigger, panel); + + expect(chat.getChat()).toEqual(descriptor); +}); + +test('the last-registered chat wins when multiple are registered', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + 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'); + jest.restoreAllMocks(); +}); + +test('open and close toggle isOpen', () => { + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + expect(chat.isOpen()).toBe(false); + chat.open(); + expect(chat.isOpen()).toBe(true); + chat.close(); + expect(chat.isOpen()).toBe(false); +}); + +test('getDisplayMode defaults to floating', () => { + expect(chat.getDisplayMode()).toBe('floating'); +}); + +test('setDisplayMode updates the display mode', () => { + chat.setDisplayMode('panel'); + expect(chat.getDisplayMode()).toBe('panel'); +}); diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts new file mode 100644 index 000000000000..b7536760926f --- /dev/null +++ b/superset-frontend/src/core/chat/index.ts @@ -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 implementation of the `chat` contribution type. + * + * Extensions register via the public `chat.registerChat()` and the host owns + * mounting, open/close state, and the display mode. Only the last-registered + * chat is active at a time. + * + * The public namespace (`chat`) is exposed to extensions on `window.superset`. + * `useChat` is host-internal and NOT part of the public `@apache-superset/core` API. + */ + +import { useSyncExternalStore } from 'react'; +import memoizeOne from 'memoize-one'; +import type { chat as chatApi } from '@apache-superset/core'; +import ChatProvider from './ChatProvider'; + +export { ChatFloatingHost, ChatPanelHost } from './ChatHost'; + +const provider = ChatProvider.getInstance(); + +const buildSnapshot = memoizeOne( + ( + open: boolean, + mode: chatApi.DisplayMode, + chat: chatApi.Chat | undefined, + trigger: ReturnType, + panel: ReturnType, + ) => ({ open, mode, chat, trigger, panel }), +); + +const getSnapshot = () => + buildSnapshot( + provider.isOpen(), + provider.getDisplayMode(), + provider.getChat(), + provider.getTrigger(), + provider.getPanel(), + ); + +/** + * Host-internal hook. Returns the current open/mode state and the active chat + * (trigger, panel, descriptor). + */ +export const useChat = () => + useSyncExternalStore(provider.subscribe, getSnapshot); + +export const chat: typeof chatApi = { + registerChat: provider.registerChat.bind(provider), + getChat: provider.getChat.bind(provider), + onDidRegisterChat: provider.onDidRegisterChat, + onDidUnregisterChat: provider.onDidUnregisterChat, + open: provider.open.bind(provider), + close: provider.close.bind(provider), + isOpen: provider.isOpen.bind(provider), + onDidOpen: provider.onDidOpen, + onDidClose: provider.onDidClose, + getDisplayMode: provider.getDisplayMode.bind(provider), + setDisplayMode: provider.setDisplayMode.bind(provider), + onDidChangeDisplayMode: provider.onDidChangeDisplayMode, + // The host fires this from its panel resizer; until that chrome exists the + // event is exposed but never fires. + onDidResizePanel: provider.onDidResizePanel, +}; 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..104e891d1f05 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(); @@ -226,8 +184,11 @@ class EditorProviders { * @param listener The listener function. * @returns A Disposable to unsubscribe. */ - public onDidRegister(listener: Listener): Disposable { - return this.registerEmitter.subscribe(listener); + public onDidRegister( + listener: Listener, + thisArgs?: unknown, + ): Disposable { + return this.registerEmitter.subscribe(listener, thisArgs); } /** @@ -237,8 +198,9 @@ class EditorProviders { */ public onDidUnregister( listener: Listener, + thisArgs?: unknown, ): Disposable { - return this.unregisterEmitter.subscribe(listener); + return this.unregisterEmitter.subscribe(listener, thisArgs); } /** @@ -248,6 +210,8 @@ class EditorProviders { this.providers.clear(); this.languageToProvider.clear(); this.syncListeners.clear(); + this.registerEmitter = createEventEmitter(); + this.unregisterEmitter = createEventEmitter(); } } diff --git a/superset-frontend/src/core/editors/index.ts b/superset-frontend/src/core/editors/index.ts index 3613b4d42474..83ecf53eaca9 100644 --- a/superset-frontend/src/core/editors/index.ts +++ b/superset-frontend/src/core/editors/index.ts @@ -18,130 +18,39 @@ */ /** - * @fileoverview Implementation of the editors API for Superset. + * @fileoverview Host implementation of the `editors` contribution type. * - * This module provides the runtime implementation of the editor registration - * and resolution functions declared in the API types. + * Extensions register via the public `editors.registerEditor()` and the host + * resolves the appropriate provider per language, falling back to the built-in + * AceEditorProvider when no extension is registered. + * + * The public namespace (`editors`) is exposed to extensions on `window.superset`. + * `EditorHost` is the host-internal component for rendering editors and is NOT + * part of the public `@apache-superset/core` API. */ import { useSyncExternalStore } from 'react'; import { editors as editorsApi } from '@apache-superset/core'; -import { Disposable } from '../models'; import EditorProviders from './EditorProviders'; -type EditorLanguage = editorsApi.EditorLanguage; -type Editor = editorsApi.Editor; -type EditorProvider = editorsApi.EditorProvider; -type EditorComponent = editorsApi.EditorComponent; -type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent; -type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent; - -/** - * Register an editor provider as a module-level side effect. - * Takes the editor descriptor directly rather than looking it up - * from a manifest by ID. - * - * @param editor The editor descriptor. - * @param component The React component implementing the editor. - * @returns A Disposable to unregister the provider. - */ -export const registerEditor = ( - editor: Editor, - component: EditorComponent, -): Disposable => { - const providers = EditorProviders.getInstance(); - return providers.registerProvider(editor, component); -}; - -/** - * Get the editor provider for a specific language. - * Returns the extension's editor if registered, otherwise undefined. - * - * @param language The language to get an editor for - * @returns The editor provider or undefined if no extension provides one - */ -export const getEditor = ( - language: EditorLanguage, -): EditorProvider | undefined => { - const manager = EditorProviders.getInstance(); - return manager.getProvider(language); -}; - -/** - * Check if an extension has registered an editor for a language. - * - * @param language The language to check - * @returns True if an extension provides an editor for this language - */ -export const hasEditor = (language: EditorLanguage): boolean => { - const manager = EditorProviders.getInstance(); - return manager.hasProvider(language); -}; - -/** - * Get all registered editor providers. - * - * @returns Array of all registered editor providers - */ -export const getAllEditors = (): EditorProvider[] => { - const manager = EditorProviders.getInstance(); - return manager.getAllProviders(); -}; - -/** - * Event fired when an editor is registered. - * Subscribe to this event to react when extensions register new editors. - */ -export const onDidRegisterEditor = ( - listener: (e: EditorRegisteredEvent) => void, -): Disposable => { - const manager = EditorProviders.getInstance(); - return manager.onDidRegister(listener); -}; +export type { EditorHostProps } from './EditorHost'; +export { default as EditorHost } from './EditorHost'; +export { default as AceEditorProvider } from './AceEditorProvider'; -/** - * Event fired when an editor is unregistered. - * Subscribe to this event to react when extensions unregister editors. - */ -export const onDidUnregisterEditor = ( - listener: (e: EditorUnregisteredEvent) => void, -): Disposable => { - const manager = EditorProviders.getInstance(); - return manager.onDidUnregister(listener); -}; +const provider = EditorProviders.getInstance(); -/** - * Hook that returns the editor provider for a specific language and re-renders when it changes. - * - * @param language The language to get an editor for - * @returns The editor provider or undefined if no extension provides one - */ -export const useEditor = ( - language: EditorLanguage, -): EditorProvider | undefined => { - const manager = EditorProviders.getInstance(); - return useSyncExternalStore( - manager.subscribe, - () => manager.getProvider(language), +export const useEditor = (language: editorsApi.EditorLanguage) => + useSyncExternalStore( + provider.subscribe, + () => provider.getProvider(language), () => undefined, ); -}; -/** - * Editors API object for use in the extension system. - */ export const editors: typeof editorsApi = { - registerEditor, - getEditor, - hasEditor, - getAllEditors, - onDidRegisterEditor, - onDidUnregisterEditor, + registerEditor: provider.registerProvider.bind(provider), + getEditor: provider.getProvider.bind(provider), + hasEditor: provider.hasProvider.bind(provider), + getAllEditors: provider.getAllProviders.bind(provider), + onDidRegisterEditor: provider.onDidRegister.bind(provider), + onDidUnregisterEditor: provider.onDidUnregister.bind(provider), }; - -export { EditorProviders }; - -// Component exports -export { default as EditorHost } from './EditorHost'; -export type { EditorHostProps } from './EditorHost'; -export { default as AceEditorProvider } from './AceEditorProvider'; 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..ce6184a90dce 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,14 @@ 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)); -}; + thisArgs?: unknown, +): Disposable => registerEmitter.subscribe(listener, thisArgs); export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem = - (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => { - unregisterListeners.add(listener); - return new Disposable(() => unregisterListeners.delete(listener)); - }; + ( + listener: (e: MenuItemUnregisteredEvent) => void, + thisArgs?: unknown, + ): Disposable => unregisterEmitter.subscribe(listener, thisArgs); 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..e7c2fbe183d7 --- /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, notifyLocationChanged } = await importNavigation(); + // The default pathname ('/') is not enumerated and falls back to home. + expect(navigation.getPage()).toBe('home'); + notifyLocationChanged('/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('notifyLocationChanged updates the current page type', async () => { + const { navigation, notifyLocationChanged } = await importNavigation(); + notifyLocationChanged('/explore/?form_data={}'); + expect(navigation.getPage()).toBe('explore'); +}); + +test('notifyLocationChanged fires listeners on page type change', async () => { + const { navigation, notifyLocationChanged } = await importNavigation(); + const listener = jest.fn(); + const disposable = navigation.onDidChangePage(listener); + + notifyLocationChanged('/superset/dashboard/1/'); + expect(listener).toHaveBeenCalledWith('dashboard'); + + disposable.dispose(); +}); + +test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => { + window.location.pathname = '/superset/dashboard/1/'; + const { navigation, notifyLocationChanged } = await importNavigation(); + const listener = jest.fn(); + navigation.onDidChangePage(listener); + + notifyLocationChanged('/superset/dashboard/2/'); + expect(listener).not.toHaveBeenCalled(); +}); + +test('onDidChangePage listener is removed after dispose', async () => { + const { navigation, notifyLocationChanged } = await importNavigation(); + const listener = jest.fn(); + const disposable = navigation.onDidChangePage(listener); + + disposable.dispose(); + notifyLocationChanged('/superset/dashboard/1/'); + expect(listener).not.toHaveBeenCalled(); +}); + +test('sqllab path is matched with and without trailing slash', async () => { + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/sqllab'); + expect(navigation.getPage()).toBe('sqllab'); + notifyLocationChanged('/explore/'); + notifyLocationChanged('/sqllab/'); + expect(navigation.getPage()).toBe('sqllab'); +}); + +test('chart and dashboard list pages get their own page types', async () => { + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/chart/list/'); + expect(navigation.getPage()).toBe('chart_list'); + notifyLocationChanged('/dashboard/list/'); + expect(navigation.getPage()).toBe('dashboard_list'); +}); + +test('dataset list and single-dataset pages get distinct page types', async () => { + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/tablemodelview/list/'); + expect(navigation.getPage()).toBe('dataset_list'); + notifyLocationChanged('/dataset/42'); + expect(navigation.getPage()).toBe('dataset'); +}); + +test('sqllab editor, query history, and saved queries get distinct page types', async () => { + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/sqllab/'); + expect(navigation.getPage()).toBe('sqllab'); + notifyLocationChanged('/sqllab/history/'); + expect(navigation.getPage()).toBe('query_history'); + notifyLocationChanged('/savedqueryview/list/'); + expect(navigation.getPage()).toBe('saved_queries'); +}); + +test('chart/add resolves to explore, not chart_list', async () => { + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/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..d41e76e50bf7 --- /dev/null +++ b/superset-frontend/src/core/navigation/index.ts @@ -0,0 +1,94 @@ +/** + * 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 implementation of the `navigation` namespace. + * + * Derives the current {@link Page} from the browser location by matching + * against {@link RoutePaths}. Call {@link useNavigationTracker} once in the + * app shell to keep the page in sync with React Router. + */ + +import { useEffect, useRef } from 'react'; +import { useLocation, matchPath } from 'react-router-dom'; +import type { navigation as navigationApi } from '@apache-superset/core'; +import { RoutePaths } from '../../views/routePaths'; +import { Disposable } from '../models'; +import { createValueEventEmitter } from '../utils'; + +type Page = navigationApi.Page; + +/** Maps route path patterns to their corresponding Page type. */ +const PAGE_ROUTES: { path: string; page: Page }[] = [ + { path: RoutePaths.DASHBOARD, page: 'dashboard' }, + { path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' }, + { path: RoutePaths.QUERY_HISTORY, page: 'query_history' }, + { path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' }, + { path: RoutePaths.SQLLAB, page: 'sqllab' }, + { path: RoutePaths.CHART_ADD, page: 'explore' }, + { path: RoutePaths.CHART_LIST, page: 'chart_list' }, + { path: RoutePaths.EXPLORE, page: 'explore' }, + { path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' }, + { path: RoutePaths.DATASET_LIST, page: 'dataset_list' }, + { path: RoutePaths.DATASET_ADD, page: 'dataset' }, + { path: RoutePaths.DATASET, page: 'dataset' }, +]; + +function derivePage(pathname: string): Page { + for (const { path, page } of PAGE_ROUTES) { + if (matchPath(pathname, { path, exact: false })) return page; + } + return 'home'; +} + +const pageEmitter = createValueEventEmitter( + derivePage(window.location.pathname), +); + +/** Updates the current page from a pathname. No-op when the page is unchanged. */ +export const notifyLocationChanged = (pathname: string): void => { + const next = derivePage(pathname); + if (next === pageEmitter.getCurrent()) return; + pageEmitter.fire(next); +}; + +const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent(); + +const onDidChangePage: typeof navigationApi.onDidChangePage = ( + listener: (page: Page) => void, + thisArgs?: unknown, +): Disposable => pageEmitter.subscribe(listener, thisArgs); + +/** Synchronizes the navigation module with React Router. Call once in the app shell. */ +export const useNavigationTracker = () => { + const location = useLocation(); + const prevPathname = useRef(null); + + useEffect(() => { + if (prevPathname.current !== location.pathname) { + prevPathname.current = location.pathname; + notifyLocationChanged(location.pathname); + } + }, [location.pathname]); +}; + +export const navigation: typeof navigationApi = { + getPage, + onDidChangePage, +}; diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts index b14be7efd07b..707b731c1b1d 100644 --- a/superset-frontend/src/core/sqlLab/index.ts +++ b/superset-frontend/src/core/sqlLab/index.ts @@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit'; import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; import { Database, Disposable } from '../models'; -import { createActionListener } from '../utils'; +import { createActionListener } from '../storeUtils'; import { Panel, Tab, diff --git a/superset-frontend/src/core/storeUtils.ts b/superset-frontend/src/core/storeUtils.ts new file mode 100644 index 000000000000..87938d02cd2d --- /dev/null +++ b/superset-frontend/src/core/storeUtils.ts @@ -0,0 +1,50 @@ +/** + * 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 { common as core } from '@apache-superset/core'; +import { listenerMiddleware, RootState, store } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; + +export function createActionListener( + predicate: AnyListenerPredicate, + listener: (v: V) => void, + valueParser: (action: A, state: RootState) => V | null | undefined, + thisArgs?: unknown, +): core.Disposable { + const boundListener = thisArgs + ? listener.bind(thisArgs as object) + : listener; + + const unsubscribe = listenerMiddleware.startListening({ + predicate, + effect: action => { + const state = store.getState(); + // The predicate already ensures the action matches type A at runtime. + const value = valueParser(action as unknown as A, state); + if (value != null) { + boundListener(value); + } + }, + }); + + return { + dispose: () => { + unsubscribe(); + }, + }; +} diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index 1e4dded93c35..536508d88a09 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -17,33 +17,54 @@ * under the License. */ import type { common as core } from '@apache-superset/core'; -import { AnyAction } from 'redux'; -import { listenerMiddleware, RootState, store } from 'src/views/store'; -import { AnyListenerPredicate } from '@reduxjs/toolkit'; -export function createActionListener( - predicate: AnyListenerPredicate, - listener: (v: V) => void, - valueParser: (action: AnyAction, state: RootState) => V | null | undefined, - thisArgs?: any, -): core.Disposable { - const boundListener = thisArgs ? listener.bind(thisArgs) : listener; +type Listener = (e: T) => unknown; - const unsubscribe = listenerMiddleware.startListening({ - predicate, - effect: (action: AnyAction) => { - const state = store.getState(); - const value = valueParser(action, state); - // Skip calling listener if valueParser returns null/undefined - if (value != null) { - boundListener(value); - } - }, - }); +/** 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 { - dispose: () => { - unsubscribe(); + fire: value => { + current = value; + fire(value); }, + subscribe, + getCurrent: () => current, }; } diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 3e8f775993e6..23f74e4de2e2 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -24,11 +24,12 @@ * Extensions register views as side effects at import time. */ -import React, { ReactElement, useSyncExternalStore } from 'react'; +import React, { ComponentType, useSyncExternalStore } from 'react'; 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; @@ -36,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent; const viewRegistry: Map< string, - { view: View; location: string; provider: () => ReactElement } + { view: View; location: string; component: ComponentType } > = new Map(); const locationIndex: Map> = new Map(); @@ -47,29 +48,29 @@ 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 = ( view: View, location: string, - provider: () => ReactElement, + component: ComponentType, ): Disposable => { const { id } = view; - viewRegistry.set(id, { view, location, provider }); + viewRegistry.set(id, { view, location, component }); const ids = locationIndex.get(location) ?? new Set(); ids.add(id); @@ -83,12 +84,16 @@ const registerView: typeof viewsApi.registerView = ( }); }; -export const resolveView = (id: string): ReactElement => { - const provider = viewRegistry.get(id)?.provider; - if (!provider) { +export const resolveView = (id: string): React.ReactElement => { + const entry = viewRegistry.get(id); + if (!entry) { return React.createElement(ExtensionPlaceholder, { id }); } - return React.createElement(ErrorBoundary, null, provider()); + return React.createElement( + ErrorBoundary, + null, + React.createElement(entry.component), + ); }; const getViews: typeof viewsApi.getViews = ( @@ -116,17 +121,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..21dbc2419f11 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -17,57 +17,52 @@ * under the License. */ import { useEffect } from 'react'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-syntax import * as supersetCore from '@apache-superset/core'; -import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { authentication, + chat, core, commands, editors, extensions, menus, + navigation, + useNavigationTracker, sqlLab, views, } from 'src/core'; 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, }) => { + useNavigationTracker(); + const userId = useSelector( ({ user }) => user.userId, ); - useEffect(() => { +useEffect(() => { if (userId == null) return; - // Provide the implementations for @apache-superset/core + // 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/utils/localStorageHelpers.ts b/superset-frontend/src/utils/localStorageHelpers.ts index 3c59b04b9c44..3bc302caf813 100644 --- a/superset-frontend/src/utils/localStorageHelpers.ts +++ b/superset-frontend/src/utils/localStorageHelpers.ts @@ -57,6 +57,7 @@ export enum LocalStorageKeys { DashboardExploreContext = 'dashboard__explore_context', DashboardEditorShowOnlyMyCharts = 'dashboard__editor_show_only_my_charts', CommonResizableSidebarWidths = 'common__resizable_sidebar_widths', + ChatState = 'chat__state', } export type LocalStorageValues = { @@ -78,6 +79,7 @@ export type LocalStorageValues = { dashboard__explore_context: Record; dashboard__editor_show_only_my_charts: boolean; common__resizable_sidebar_widths: Record; + chat__state: { open: boolean; mode: string }; }; /* diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 8562748523ea..5051e7b0eb75 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -39,7 +39,12 @@ 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 { isUser } from 'src/types/bootstrapTypes'; import ExtensionsStartup from 'src/extensions/ExtensionsStartup'; +import { Splitter } from 'src/components/Splitter'; +import { ChatFloatingHost, ChatPanelHost, useChat } from 'src/core/chat'; +import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -79,42 +84,137 @@ const LocationPathnameLogger = () => { return <>; }; +const CHAT_PANEL_DEFAULT_WIDTH = 400; +const CHAT_PANEL_MIN_WIDTH = 280; + +const RouteSwitch = () => ( + + {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( + + }> + + + + + + ))} + + +); + +const layoutCss = css` + flex: 1; + min-height: 0; + overflow: hidden; +`; + +const contentCss = css` + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + position: relative; +`; + +/** + * Renders the main content area. When the chat panel is open in panel mode, + * wraps and in a Splitter so they sit side-by-side + * with a lazy drag bar (blue preview line, resize committed on mouseup). + * The full tree lives inside the first panel so its internal flex + * context is preserved — SQL Lab, Explore, and other pages are unaffected. + */ +const AppContent = () => { + const isAuthenticated = + isUser(bootstrapData.user) && !bootstrapData.user.isAnonymous; + const chatExtensionsEnabled = + isFeatureEnabled(FeatureFlag.EnableExtensions) && isAuthenticated; + const { open: panelOpen, mode, chat } = useChat(); + const hasChatExtension = chatExtensionsEnabled && !!chat; + const isPanelOpen = hasChatExtension && mode === 'panel' && panelOpen; + + const [storedWidth, setStoredWidth] = useStoredSidebarWidth( + 'chat:panel', + CHAT_PANEL_DEFAULT_WIDTH, + ); + + const layoutContent = ( + + + + + + ); + + if (!isPanelOpen) { + return ( + <> + {layoutContent} + {hasChatExtension && } + + ); + } + + return ( + { + const chatWidth = sizes[sizes.length - 1]; + if ( + typeof chatWidth === 'number' && + chatWidth >= CHAT_PANEL_MIN_WIDTH + ) { + setStoredWidth(chatWidth); + } + }} + css={css` + flex: 1; + min-height: 0; + overflow: hidden; + + /* + * Splitter.Panel is not a flex container by default, so flex:1 on + * children (Layout, ChatPanelHost) has no height effect and + * panels collapse. Making them flex columns lets children fill them. + */ + & > .ant-splitter-panel { + display: flex !important; + flex-direction: column; + } + `} + > + {layoutContent} + + + + + ); +}; + const App = () => ( - - - - {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( - - }> - - - - - - - - - - ))} - - - +
+ + + + +
diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts new file mode 100644 index 000000000000..7cc682ea632e --- /dev/null +++ b/superset-frontend/src/views/routePaths.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. + */ + +export const RoutePaths = { + REDIRECT: '/redirect/', + LOGIN: '/login/', + REGISTER_ACTIVATION: '/register/activation/:activationHash', + REGISTER: '/register/', + LOGOUT: '/logout/', + HOME: '/superset/welcome/', + FILE_HANDLER: '/superset/file-handler', + DASHBOARD: '/superset/dashboard/:idOrSlug/', + DASHBOARD_LIST: '/dashboard/list/', + CHART_ADD: '/chart/add', + CHART_LIST: '/chart/list/', + DATASET_LIST: '/tablemodelview/list/', + DATABASE_LIST: '/databaseview/list/', + SAVED_QUERIES: '/savedqueryview/list/', + CSS_TEMPLATES: '/csstemplatemodelview/list/', + THEMES: '/theme/list/', + ANNOTATION_LAYERS: '/annotationlayer/list/', + ANNOTATION_LIST: '/annotationlayer/:annotationLayerId/annotation/', + QUERY_HISTORY: '/sqllab/history/', + ALERTS: '/alert/list/', + REPORTS: '/report/list/', + ALERT_LOG: '/alert/:alertId/log/', + REPORT_LOG: '/report/:alertId/log/', + EXPLORE: '/explore/', + EXPLORE_PERMALINK: '/superset/explore/p', + DATASET_ADD: '/dataset/add/', + DATASET: '/dataset/:datasetId', + ROW_LEVEL_SECURITY: '/rowlevelsecurity/list', + TASKS: '/tasks/list/', + SQLLAB: '/sqllab/', + USER_INFO: '/user_info/', + ACTION_LOG: '/actionlog/list', + REGISTRATIONS: '/registrations/', + ALL_ENTITIES: '/superset/all_entities/', + TAGS: '/superset/tags/', + ROLES: '/roles/', + USERS: '/users/', + GROUPS: '/list_groups/', + EXTENSIONS: '/extensions/list/', +} as const; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 4f066e3ec2cb..4c18e28ab427 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -26,6 +26,7 @@ import { } from 'react'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import getBootstrapData from 'src/utils/getBootstrapData'; +import { RoutePaths } from './routePaths'; // not lazy loaded since this is the home page. import Home from 'src/pages/Home'; @@ -189,158 +190,58 @@ const RedirectWarning = lazy( type Routes = { path: string; - Component: ComponentType; - Fallback?: ComponentType; + Component: ComponentType; + Fallback?: ComponentType; props?: ComponentProps; }[]; export const routes: Routes = [ - { - path: '/redirect/', - Component: RedirectWarning, - }, - { - path: '/login/', - Component: Login, - }, - { - path: '/register/activation/:activationHash', - Component: Register, - }, - { - path: '/register/', - Component: Register, - }, - { - path: '/logout/', - Component: Login, - }, - { - path: '/superset/welcome/', - Component: Home, - }, - { - path: '/superset/file-handler', - Component: FileHandler, - }, - { - path: '/dashboard/list/', - Component: DashboardList, - }, - { - path: '/superset/dashboard/:idOrSlug/', - Component: Dashboard, - }, - { - path: '/chart/add', - Component: ChartCreation, - }, - { - path: '/chart/list/', - Component: ChartList, - }, - { - path: '/tablemodelview/list/', - Component: DatasetList, - }, - { - path: '/databaseview/list/', - Component: DatabaseList, - }, - { - path: '/savedqueryview/list/', - Component: SavedQueryList, - }, - { - path: '/csstemplatemodelview/list/', - Component: CssTemplateList, - }, - { - path: '/theme/list/', - Component: ThemeList, - }, - { - path: '/annotationlayer/list/', - Component: AnnotationLayerList, - }, - { - path: '/annotationlayer/:annotationLayerId/annotation/', - Component: AnnotationList, - }, - { - path: '/sqllab/history/', - Component: QueryHistoryList, - }, - { - path: '/alert/list/', - Component: AlertReportList, - }, - { - path: '/report/list/', + { path: RoutePaths.REDIRECT, Component: RedirectWarning }, + { path: RoutePaths.LOGIN, Component: Login }, + { path: RoutePaths.REGISTER_ACTIVATION, Component: Register }, + { path: RoutePaths.REGISTER, Component: Register }, + { path: RoutePaths.LOGOUT, Component: Login }, + { path: RoutePaths.HOME, Component: Home }, + { path: RoutePaths.FILE_HANDLER, Component: FileHandler }, + { path: RoutePaths.DASHBOARD_LIST, Component: DashboardList }, + { path: RoutePaths.DASHBOARD, Component: Dashboard }, + { path: RoutePaths.CHART_ADD, Component: ChartCreation }, + { path: RoutePaths.CHART_LIST, Component: ChartList }, + { path: RoutePaths.DATASET_LIST, Component: DatasetList }, + { path: RoutePaths.DATABASE_LIST, Component: DatabaseList }, + { path: RoutePaths.SAVED_QUERIES, Component: SavedQueryList }, + { path: RoutePaths.CSS_TEMPLATES, Component: CssTemplateList }, + { path: RoutePaths.THEMES, Component: ThemeList }, + { path: RoutePaths.ANNOTATION_LAYERS, Component: AnnotationLayerList }, + { path: RoutePaths.ANNOTATION_LIST, Component: AnnotationList }, + { path: RoutePaths.QUERY_HISTORY, Component: QueryHistoryList }, + { path: RoutePaths.ALERTS, Component: AlertReportList }, + { + path: RoutePaths.REPORTS, Component: AlertReportList, - props: { - isReportEnabled: true, - }, - }, - { - path: '/alert/:alertId/log/', - Component: ExecutionLogList, + props: { isReportEnabled: true }, }, + { path: RoutePaths.ALERT_LOG, Component: ExecutionLogList }, { - path: '/report/:alertId/log/', + path: RoutePaths.REPORT_LOG, Component: ExecutionLogList, - props: { - isReportEnabled: true, - }, - }, - { - path: '/explore/', - Component: Chart, - }, - { - path: '/superset/explore/p', - Component: Chart, - }, - { - path: '/dataset/add/', - Component: DatasetCreation, - }, - { - path: '/dataset/:datasetId', - Component: DatasetCreation, - }, - { - path: '/rowlevelsecurity/list', - Component: RowLevelSecurityList, - }, - { - path: '/tasks/list/', - Component: TaskList, - }, - { - path: '/sqllab/', - Component: SqlLab, - }, - { path: '/user_info/', Component: UserInfo }, - { - path: '/actionlog/list', - Component: ActionLogList, - }, - { - path: '/registrations/', - Component: UserRegistrations, - }, + props: { isReportEnabled: true }, + }, + { path: RoutePaths.EXPLORE, Component: Chart }, + { path: RoutePaths.EXPLORE_PERMALINK, Component: Chart }, + { path: RoutePaths.DATASET_ADD, Component: DatasetCreation }, + { path: RoutePaths.DATASET, Component: DatasetCreation }, + { path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList }, + { path: RoutePaths.TASKS, Component: TaskList }, + { path: RoutePaths.SQLLAB, Component: SqlLab }, + { path: RoutePaths.USER_INFO, Component: UserInfo }, + { path: RoutePaths.ACTION_LOG, Component: ActionLogList }, + { path: RoutePaths.REGISTRATIONS, Component: UserRegistrations }, ]; if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { - routes.push({ - path: '/superset/all_entities/', - Component: AllEntities, - }); - routes.push({ - path: '/superset/tags/', - Component: Tags, - }); + routes.push({ path: RoutePaths.ALL_ENTITIES, Component: AllEntities }); + routes.push({ path: RoutePaths.TAGS, Component: Tags }); } const user = getBootstrapData()?.user; @@ -350,33 +251,18 @@ const isAdmin = isUserAdmin(user); if (isAdmin) { routes.push( - { - path: '/roles/', - Component: RolesList, - }, - { - path: '/users/', - Component: UsersList, - }, - { - path: '/list_groups/', - Component: GroupsList, - }, + { path: RoutePaths.ROLES, Component: RolesList }, + { path: RoutePaths.USERS, Component: UsersList }, + { path: RoutePaths.GROUPS, Component: GroupsList }, ); if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { - routes.push({ - path: '/extensions/list/', - Component: Extensions, - }); + routes.push({ path: RoutePaths.EXTENSIONS, Component: Extensions }); } } if (authRegistrationEnabled) { - routes.push({ - path: '/registrations/', - Component: UserRegistrations, - }); + routes.push({ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations }); } const frontEndRoutes: Record = routes 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 "",