mirror of
https://github.com/apache/superset.git
synced 2026-06-19 22:49:18 +00:00
Compare commits
4 Commits
enxdev/cha
...
fix-sql-la
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c964696723 | ||
|
|
1f8e8b4cc2 | ||
|
|
3d24afdabf | ||
|
|
c7a09acd01 |
@@ -7235,10 +7235,10 @@
|
||||
"pypi_packages": [
|
||||
"oracledb"
|
||||
],
|
||||
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
|
||||
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
|
||||
"default_port": 1521,
|
||||
"notes": "Previously used cx_Oracle, now uses oracledb.",
|
||||
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
|
||||
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
|
||||
"category": "Other Databases"
|
||||
},
|
||||
"engine": "oracle",
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.16.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -62,9 +62,6 @@ spec:
|
||||
{{- if .Values.init.initContainers }}
|
||||
initContainers: {{- tpl (toYaml .Values.init.initContainers) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hostAliases }}
|
||||
hostAliases: {{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ template "superset.name" . }}-init-db
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
|
||||
@@ -53,7 +53,7 @@ dependencies = [
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
"flask-migrate>=4.1.0, <5.0",
|
||||
"flask-migrate>=3.1.0, <5.0",
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
@@ -177,7 +177,7 @@ ocient = [
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
oracle = ["oracledb>=2.0.0, <5"]
|
||||
oracle = ["cx-Oracle>8.0.0, <8.4"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.60.0, <2"]
|
||||
|
||||
@@ -141,7 +141,7 @@ flask-login==0.6.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
flask-migrate==4.1.0
|
||||
flask-migrate==3.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-session==0.8.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -293,7 +293,7 @@ flask-login==0.6.3
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
flask-migrate==4.1.0
|
||||
flask-migrate==3.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
54
superset-frontend/package-lock.json
generated
54
superset-frontend/package-lock.json
generated
@@ -8450,6 +8450,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8467,6 +8470,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8484,6 +8490,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8501,6 +8510,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8518,6 +8530,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8535,6 +8550,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8552,6 +8570,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8569,6 +8590,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -26108,21 +26132,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -43235,21 +43244,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -18,14 +18,6 @@
|
||||
"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"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Chat contribution API for Superset extensions.
|
||||
*
|
||||
* Chat is a dedicated contribution type (not a view): an extension registers
|
||||
* a chat via {@link registerChat} and the host owns where and how it is
|
||||
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||
* may register, but exactly one is active at a time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { chat } from '@apache-superset/core';
|
||||
*
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { Disposable, Event } from '../common';
|
||||
|
||||
export interface Chat {
|
||||
/** The unique identifier for the chat. */
|
||||
id: string;
|
||||
/** The display name of the chat. */
|
||||
name: string;
|
||||
/** Optional description of the chat, for display in contribution manifests. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type DisplayMode = 'floating' | 'panel';
|
||||
|
||||
/**
|
||||
* Registers a chat provider. The host applies singleton resolution — only one
|
||||
* chat is active at a time: the most recently registered chat wins, and
|
||||
* disposing it restores the previously registered one. Re-registering an id
|
||||
* replaces that registration in place.
|
||||
*
|
||||
* When a registration with a different id takes over the active slot (or the
|
||||
* active chat is disposed), the host closes the panel first, firing
|
||||
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
|
||||
*
|
||||
* Disposing the returned Disposable unregisters the chat.
|
||||
*
|
||||
* @param chat The chat descriptor (id, name).
|
||||
* @param trigger A function returning the collapsed bubble element. Owned by
|
||||
* the extension — dynamic state such as unread counts and badges lives here.
|
||||
* Hidden by the host when in panel mode.
|
||||
* @param panel A function returning the chat panel element. Mounted by the
|
||||
* host as a floating overlay in 'floating' mode, or docked at the side of
|
||||
* the viewport in 'panel' mode (the reference host docks a fixed-width
|
||||
* overlay at the right edge; hosts may integrate a true layout slot
|
||||
* instead). Same component in both modes.
|
||||
* @returns A Disposable that unregisters the chat when disposed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerChat(
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Returns the active chat descriptor.
|
||||
*
|
||||
* @returns A copy of the active Chat descriptor, or undefined if none is
|
||||
* registered. Mutating the returned object has no effect on the registry.
|
||||
*/
|
||||
export declare function getChat(): Chat | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is registered.
|
||||
*/
|
||||
export declare const onDidRegisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Opens the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when no chat is registered or the panel is already open.
|
||||
*/
|
||||
export declare function open(): void;
|
||||
|
||||
/**
|
||||
* Closes the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when the panel is not open.
|
||||
*/
|
||||
export declare function close(): void;
|
||||
|
||||
/**
|
||||
* Returns whether the active chat's panel is currently open.
|
||||
*
|
||||
* @returns True if the chat panel is open.
|
||||
*/
|
||||
export declare function isOpen(): boolean;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel opens. Also fired by the host's own
|
||||
* controls, not only by an extension's open() call.
|
||||
*/
|
||||
export declare const onDidOpen: Event<void>;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel closes. Also fired when the host closes the
|
||||
* panel itself, e.g. because the active chat was disposed or displaced by a
|
||||
* different chat.
|
||||
*/
|
||||
export declare const onDidClose: Event<void>;
|
||||
|
||||
/**
|
||||
* Returns the current display mode.
|
||||
*
|
||||
* @returns The current DisplayMode.
|
||||
*/
|
||||
export declare function getDisplayMode(): DisplayMode;
|
||||
|
||||
/**
|
||||
* Sets the display mode.
|
||||
*
|
||||
* The mode is host-global and applies to whichever chat is active, regardless
|
||||
* of which extension calls it. Hosts may also change the mode through their
|
||||
* own controls — use onDidChangeDisplayMode to observe all changes rather than
|
||||
* assuming the last setDisplayMode() call won.
|
||||
*
|
||||
* @param displayMode The display mode to switch to.
|
||||
*/
|
||||
export declare function setDisplayMode(displayMode: DisplayMode): void;
|
||||
|
||||
/**
|
||||
* Event fired when the display mode changes, whether triggered by an
|
||||
* extension via setDisplayMode() or by host-provided controls.
|
||||
*/
|
||||
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
|
||||
|
||||
/**
|
||||
* Event fired when the panel is resized in panel mode.
|
||||
*
|
||||
* The host owns the resizer handle and drag interaction; a host without a
|
||||
* resizer never fires this event. (The reference host mounts the panel at a
|
||||
* fixed width and does not provide a resizer, so subscribers receive no
|
||||
* events there.) Listen to this event to adapt internal layout to the
|
||||
* available width; do not rely on it firing.
|
||||
*/
|
||||
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||
|
||||
// TODO: client actions API — tool availability functions will be added here
|
||||
// once the client_actions SIP is finalized. The chat namespace is the
|
||||
// intended integration point between the two SIPs.
|
||||
@@ -223,6 +223,8 @@ 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 */
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
* 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, chat) and re-exported here for the manifest schema.
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -72,8 +71,7 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -84,10 +82,4 @@ 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;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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<Page>;
|
||||
@@ -39,8 +39,10 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
|
||||
<div data-test="mock-async-select" />
|
||||
));
|
||||
jest.mock('src/core/editors', () => ({
|
||||
EditorHost: ({ value }: { value: string }) => (
|
||||
<div data-test="mock-async-ace-editor">{value}</div>
|
||||
EditorHost: ({ value, height }: { value: string; height: string }) => (
|
||||
<div data-test="mock-async-ace-editor" data-height={height}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,18 @@ describe('TemplateParamsEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders the editor with a bounded height to avoid overflowing the popover', async () => {
|
||||
const { container, getByTestId } = setup();
|
||||
fireEvent.click(getByText(container, 'Parameters'));
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId('mock-async-ace-editor')).toHaveAttribute(
|
||||
'data-height',
|
||||
'360px',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders templateParams', async () => {
|
||||
const { container, getByTestId } = setup();
|
||||
fireEvent.click(getByText(container, 'Parameters'));
|
||||
|
||||
@@ -30,10 +30,9 @@ import {
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
|
||||
const StyledEditorHost = styled(EditorHost)`
|
||||
&.ace_editor {
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
}
|
||||
const EditorOutline = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
`;
|
||||
|
||||
const StyledParagraph = styled.p`
|
||||
@@ -87,14 +86,16 @@ const TemplateParamsEditor = ({
|
||||
</a>{' '}
|
||||
{t('syntax.')}
|
||||
</StyledParagraph>
|
||||
<StyledEditorHost
|
||||
id={`template-params-${queryEditorId}`}
|
||||
height="800px"
|
||||
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
|
||||
language={language === 'yaml' ? 'yaml' : 'json'}
|
||||
width="100%"
|
||||
value={code}
|
||||
/>
|
||||
<EditorOutline>
|
||||
<EditorHost
|
||||
id={`template-params-${queryEditorId}`}
|
||||
height="360px"
|
||||
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
|
||||
language={language === 'yaml' ? 'yaml' : 'json'}
|
||||
width="100%"
|
||||
value={code}
|
||||
/>
|
||||
</EditorOutline>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, render, screen } from 'spec/helpers/testing-library';
|
||||
import { chat } from 'src/core/chat';
|
||||
import ChatMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setDisplayMode('floating');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders nothing when no chat extension is registered', () => {
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the trigger bubble of the registered chat', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
// The panel stays unmounted until the chat is opened.
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mounts the panel when the chat opens and unmounts it on close', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => chat.open());
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
// In floating mode the trigger stays mounted alongside the open panel.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the last-registered chat when several are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
// 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(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a takeover mounts the incoming chat closed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
expect(screen.getByText('First Panel')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The displaced chat's open state must not leak into the winner.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('panel mode docks the open panel and hides the trigger', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => {
|
||||
chat.setDisplayMode('panel');
|
||||
chat.open();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
// A closed chat in panel mode renders nothing — the trigger is hidden too.
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a crashing panel does not take the trigger down with it', () => {
|
||||
const FailingPanel = () => {
|
||||
throw new Error('panel blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <FailingPanel />,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
|
||||
// The panel's boundary contains the crash; the trigger keeps rendering so
|
||||
// the user is not stranded without a way back.
|
||||
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing trigger so it does not crash the host', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
// The mount slot still renders (the boundary lives inside it), confirming
|
||||
// the provider was actually exercised and contained.
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a chat whose provider function itself throws', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
|
||||
// synchronous throws from the provider function, not just from its output.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers from a crashed chat when a different chat takes over', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('first chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary is keyed per registration, so the latched crash from the
|
||||
// first chat does not blank the second one.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('broken release');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <div>Fixed Bubble</div>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Same id, new registrationId: the remounted boundary renders the fix.
|
||||
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { type ReactElement, useRef, useSyncExternalStore } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
|
||||
|
||||
const CHAT_EDGE_MARGIN = 24;
|
||||
const PANEL_MODE_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Wraps a chat provider in a React component so that ErrorBoundary can catch
|
||||
* synchronous throws from the provider function itself. Calling `provider()`
|
||||
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
|
||||
* boundary and crash the host.
|
||||
*/
|
||||
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
|
||||
provider();
|
||||
|
||||
const ChatMount = () => {
|
||||
const theme = useTheme();
|
||||
// Notify at most once per registration; a crash can re-render and would
|
||||
// otherwise re-toast, while a replacement (new registrationId) deserves a
|
||||
// fresh notification if it crashes too.
|
||||
const crashNotifiedFor = useRef<number | null>(null);
|
||||
|
||||
// The active chat, the open state, and the display mode are read from one
|
||||
// immutable registry snapshot so a render never mixes state from two
|
||||
// different store versions (the tearing useSyncExternalStore prevents).
|
||||
const {
|
||||
open: panelOpen,
|
||||
mode,
|
||||
active,
|
||||
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { registrationId } = active;
|
||||
|
||||
const onProviderError = (error: Error) => {
|
||||
// Fault isolation: contain the crash, log it, surface a one-time
|
||||
// notification, and leave the slot empty rather than parking a
|
||||
// persistent error card.
|
||||
logging.error('[chat] provider crashed', error);
|
||||
if (crashNotifiedFor.current !== registrationId) {
|
||||
crashNotifiedFor.current = registrationId;
|
||||
store.dispatch(addDangerToast(t('The chat failed to load.')));
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'panel') {
|
||||
// Panel mode hides the trigger and docks the panel to the right edge.
|
||||
// Interim approximation of the "layout slot between header and footer"
|
||||
// from the chat API contract — the dock overlays the page until the host
|
||||
// grows a real layout slot and resizer chrome.
|
||||
if (!panelOpen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: ${PANEL_MODE_WIDTH}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
box-shadow: ${theme.boxShadow};
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
key={registrationId}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHAT_EDGE_MARGIN}px;
|
||||
bottom: ${CHAT_EDGE_MARGIN}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
{/*
|
||||
Each provider gets its own boundary so a crashing panel cannot take
|
||||
the trigger down with it (the trigger is the user's only way back).
|
||||
Keyed by registrationId: Superset's ErrorBoundary latches its error
|
||||
state, so a takeover, fallback, or same-id re-registration must
|
||||
remount the boundary to recover.
|
||||
*/}
|
||||
{panelOpen && (
|
||||
<ErrorBoundary
|
||||
key={`panel-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary
|
||||
key={`trigger-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.trigger} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMount;
|
||||
@@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createElement } from 'react';
|
||||
import { chat, getActiveChat, getChatSnapshot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
const trigger = () => createElement('button', null, 'Bubble');
|
||||
const panel = () => createElement('div', null, 'Panel');
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setDisplayMode('floating');
|
||||
});
|
||||
|
||||
test('getChat returns undefined when no chat is registered', () => {
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
expect(getActiveChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerChat resolves the registered chat with its providers', () => {
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.getChat()).toEqual(descriptor);
|
||||
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
|
||||
});
|
||||
|
||||
test('getChat returns a copy that cannot mutate the registry', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
|
||||
);
|
||||
|
||||
const copy = chat.getChat();
|
||||
copy!.name = 'Hijacked';
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme Chat');
|
||||
});
|
||||
|
||||
test('the last-registered chat wins when multiple are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
});
|
||||
|
||||
test('disposing the active chat falls back to the previous registration', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
|
||||
second.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
});
|
||||
|
||||
test('re-registering an id replaces the previous registration', () => {
|
||||
const stalePanel = () => createElement('div', null, 'Stale');
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme v2');
|
||||
expect(getActiveChat()?.panel).toBe(panel);
|
||||
});
|
||||
|
||||
test('each registration gets a distinct registrationId, including same-id replacements', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
const first = getActiveChat()?.registrationId;
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
const second = getActiveChat()?.registrationId;
|
||||
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('disposing a registration twice unregisters only once', () => {
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(chat.onDidUnregisterChat(unregistered));
|
||||
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
registration.dispose();
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledTimes(1);
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
|
||||
const registered = jest.fn();
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(
|
||||
chat.onDidRegisterChat(registered),
|
||||
chat.onDidUnregisterChat(unregistered),
|
||||
);
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
const registration = chat.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(registered).toHaveBeenCalledWith(descriptor);
|
||||
expect(unregistered).not.toHaveBeenCalled();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledWith(descriptor);
|
||||
});
|
||||
|
||||
test('a disposed event subscription stops receiving notifications', () => {
|
||||
const registered = jest.fn();
|
||||
const subscription = chat.onDidRegisterChat(registered);
|
||||
subscription.dispose();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(registered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('open and close toggle the panel and fire once', () => {
|
||||
const opened = jest.fn();
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
chat.open();
|
||||
// Opening an already-open panel is a no-op and must not re-fire.
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
|
||||
chat.close();
|
||||
chat.close();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('open is a no-op while no chat is registered', () => {
|
||||
const opened = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened));
|
||||
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
|
||||
// A registration arriving later therefore starts closed.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('a takeover by a different id closes the displaced chat panel', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const first = { id: 'first.chat', name: 'First' };
|
||||
disposables.push(chat.registerChat(first, trigger, panel));
|
||||
chat.open();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
// The incoming chat must not mount into an open state it never requested.
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a same-id replacement keeps the open state', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
// Upgrade in place: same id, new providers.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the active chat while open closes it; the fallback starts closed', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = { id: 'second.chat', name: 'Second' };
|
||||
const registration = chat.registerChat(second, trigger, panel);
|
||||
chat.open();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('disposing an inactive registration leaves the open state untouched', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const inactive = chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
inactive.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the last chat while open resets the open state', () => {
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
chat.open();
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
// A registration arriving much later must not inherit a stale open state.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('mode defaults to floating and setDisplayMode fires only on change', () => {
|
||||
const modeChanged = jest.fn();
|
||||
disposables.push(chat.onDidChangeDisplayMode(modeChanged));
|
||||
|
||||
expect(chat.getDisplayMode()).toBe('floating');
|
||||
|
||||
// Setting the current mode is a no-op.
|
||||
chat.setDisplayMode('floating');
|
||||
expect(modeChanged).not.toHaveBeenCalled();
|
||||
|
||||
chat.setDisplayMode('panel');
|
||||
expect(chat.getDisplayMode()).toBe('panel');
|
||||
expect(modeChanged).toHaveBeenCalledWith('panel');
|
||||
});
|
||||
|
||||
test('the snapshot is immutable per version and consistent with the registry', () => {
|
||||
const before = getChatSnapshot();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
const after = getChatSnapshot();
|
||||
// Unchanged references for old snapshots; a new object per change.
|
||||
expect(after).not.toBe(before);
|
||||
expect(before.active).toBeUndefined();
|
||||
expect(after).toMatchObject({
|
||||
open: true,
|
||||
mode: 'floating',
|
||||
active: getActiveChat(),
|
||||
});
|
||||
expect(after.version).toBeGreaterThan(before.version);
|
||||
// Stable reference between changes.
|
||||
expect(getChatSnapshot()).toBe(after);
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host implementation of the `chat` contribution type.
|
||||
*
|
||||
* Chat is a dedicated contribution type, not a view: extensions register via
|
||||
* the public `chat.registerChat()` and the host owns mounting, open/close
|
||||
* state, and the display mode. Multiple chat extensions may register, but the
|
||||
* host applies singleton resolution — the most-recently-registered chat is
|
||||
* active; disposing it falls back to the previous one.
|
||||
*
|
||||
* Open-state policy across active-chat transitions: when the active chat's
|
||||
* identity changes — a takeover by a different id, disposal falling back to a
|
||||
* different id, or disposal of the last chat — the panel is closed (firing
|
||||
* `onDidClose`) so the incoming chat never mounts into an open state it did
|
||||
* not request. A same-id re-registration is an upgrade in place and keeps the
|
||||
* open state.
|
||||
*
|
||||
* The public namespace (`chat`) is exposed to extensions on
|
||||
* `window.superset`; the other exports are host-internal accessors for
|
||||
* ChatMount and are NOT part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { chat as chatApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createValueEventEmitter, createEventEmitter } from '../utils';
|
||||
|
||||
type Chat = chatApi.Chat;
|
||||
type DisplayMode = chatApi.DisplayMode;
|
||||
|
||||
/** A registered chat: its descriptor plus the host-mountable providers. */
|
||||
export interface RegisteredChat {
|
||||
/** The chat descriptor passed to `registerChat`. */
|
||||
chat: Chat;
|
||||
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
|
||||
trigger: () => ReactElement;
|
||||
/** Renders the chat panel, mounted per the current {@link DisplayMode}. */
|
||||
panel: () => ReactElement;
|
||||
/**
|
||||
* Unique per registration (a same-id re-registration gets a new one). The
|
||||
* host UI keys mounts and fault containment on it, so a replacement resets
|
||||
* crashed error boundaries instead of inheriting their latched state.
|
||||
*/
|
||||
registrationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of the whole chat state, rebuilt on every change.
|
||||
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
|
||||
* consumers read registrations, open state, and mode from one consistent
|
||||
* object instead of tearing across separate live reads.
|
||||
*/
|
||||
export interface ChatSnapshot {
|
||||
/** Monotonic change counter, useful as a memo/effect dependency. */
|
||||
version: number;
|
||||
/** Whether the active chat's panel is open. */
|
||||
open: boolean;
|
||||
/** The current display mode. */
|
||||
mode: DisplayMode;
|
||||
/** The active registration, or undefined when none is registered. */
|
||||
active: RegisteredChat | undefined;
|
||||
}
|
||||
|
||||
/** Registration order is the singleton-resolution order: last entry wins. */
|
||||
const registrations: RegisteredChat[] = [];
|
||||
|
||||
let panelOpen = false;
|
||||
let nextRegistrationId = 1;
|
||||
|
||||
const registerEmitter = createEventEmitter<Chat>();
|
||||
const unregisterEmitter = createEventEmitter<Chat>();
|
||||
const openEmitter = createEventEmitter<void>();
|
||||
const closeEmitter = createEventEmitter<void>();
|
||||
const resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||
const modeEmitter = createValueEventEmitter<DisplayMode>('floating');
|
||||
|
||||
/**
|
||||
* Host-internal: resolves the active chat with its providers.
|
||||
* The most-recently-registered chat wins; when it is disposed the previous
|
||||
* registration takes over the slot again.
|
||||
*/
|
||||
export const getActiveChat = (): RegisteredChat | undefined =>
|
||||
registrations[registrations.length - 1];
|
||||
|
||||
let snapshot: ChatSnapshot = {
|
||||
version: 0,
|
||||
open: false,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: undefined,
|
||||
};
|
||||
|
||||
const stateSubscribers = new Set<() => void>();
|
||||
|
||||
const notifyState = () => {
|
||||
snapshot = {
|
||||
version: snapshot.version + 1,
|
||||
open: panelOpen,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: getActiveChat(),
|
||||
};
|
||||
stateSubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
export const subscribeToChatState = (listener: () => void): (() => void) => {
|
||||
stateSubscribers.add(listener);
|
||||
return () => {
|
||||
stateSubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getChatSnapshot = (): ChatSnapshot => snapshot;
|
||||
|
||||
/** Closes the panel and fires `onDidClose`. */
|
||||
const closePanel = () => {
|
||||
panelOpen = false;
|
||||
closeEmitter.fire();
|
||||
};
|
||||
|
||||
const registerChat: typeof chatApi.registerChat = (
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable => {
|
||||
const previousActive = getActiveChat();
|
||||
|
||||
// Re-registering an id replaces the previous entry and moves it to the
|
||||
// most-recent position, mirroring the view registry's same-id semantics.
|
||||
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
|
||||
if (existingIndex !== -1) {
|
||||
registrations.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
const entry: RegisteredChat = {
|
||||
chat,
|
||||
trigger,
|
||||
panel,
|
||||
registrationId: nextRegistrationId,
|
||||
};
|
||||
nextRegistrationId += 1;
|
||||
registrations.push(entry);
|
||||
registerEmitter.fire(chat);
|
||||
|
||||
// A takeover by a different id closes the displaced chat's panel so the
|
||||
// incoming chat never mounts already-open; a same-id replacement is an
|
||||
// upgrade in place and keeps the open state.
|
||||
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
|
||||
closePanel();
|
||||
}
|
||||
notifyState();
|
||||
|
||||
return new Disposable(() => {
|
||||
const index = registrations.indexOf(entry);
|
||||
if (index === -1) {
|
||||
// Already removed — replaced by a same-id registration or disposed twice.
|
||||
return;
|
||||
}
|
||||
const wasActive = getActiveChat() === entry;
|
||||
registrations.splice(index, 1);
|
||||
unregisterEmitter.fire(chat);
|
||||
// Disposing the active chat closes its panel; the fallback chat (if any)
|
||||
// starts closed. Disposing an inactive registration leaves the open
|
||||
// state of the active chat untouched.
|
||||
if (panelOpen && wasActive) {
|
||||
closePanel();
|
||||
}
|
||||
notifyState();
|
||||
});
|
||||
};
|
||||
|
||||
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
|
||||
const active = getActiveChat();
|
||||
// Copy so extensions cannot mutate another extension's descriptor.
|
||||
return active ? { ...active.chat } : undefined;
|
||||
};
|
||||
|
||||
const open: typeof chatApi.open = (): void => {
|
||||
const active = getActiveChat();
|
||||
// Open state only exists while a chat is registered; opening an empty slot
|
||||
// would otherwise leak `open` into a future, unrelated registration.
|
||||
if (panelOpen || !active) return;
|
||||
panelOpen = true;
|
||||
openEmitter.fire();
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const close: typeof chatApi.close = (): void => {
|
||||
const active = getActiveChat();
|
||||
if (!panelOpen || !active) return;
|
||||
closePanel();
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
|
||||
|
||||
const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
|
||||
modeEmitter.getCurrent();
|
||||
|
||||
const setDisplayMode: typeof chatApi.setDisplayMode = (
|
||||
displayMode: DisplayMode,
|
||||
): void => {
|
||||
if (displayMode === modeEmitter.getCurrent()) return;
|
||||
modeEmitter.fire(displayMode);
|
||||
notifyState();
|
||||
};
|
||||
|
||||
export const chat: typeof chatApi = {
|
||||
registerChat,
|
||||
getChat,
|
||||
onDidRegisterChat: registerEmitter.subscribe,
|
||||
onDidUnregisterChat: unregisterEmitter.subscribe,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
onDidOpen: openEmitter.subscribe,
|
||||
onDidClose: closeEmitter.subscribe,
|
||||
getDisplayMode,
|
||||
setDisplayMode,
|
||||
onDidChangeDisplayMode: modeEmitter.subscribe,
|
||||
// The host fires this from its panel resizer; until that chrome exists the
|
||||
// event is exposed but never fires.
|
||||
onDidResizePanel: resizePanelEmitter.subscribe,
|
||||
};
|
||||
@@ -254,6 +254,33 @@ 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();
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProvider = editors.EditorProvider;
|
||||
@@ -28,8 +27,45 @@ type EditorComponent = editors.EditorComponent;
|
||||
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
|
||||
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
|
||||
|
||||
/**
|
||||
* Listener function type for events.
|
||||
*/
|
||||
type Listener<T> = (e: T) => void;
|
||||
|
||||
/**
|
||||
* Simple event emitter for editor provider lifecycle events.
|
||||
*/
|
||||
class EventEmitter<T> {
|
||||
private listeners: Set<Listener<T>> = 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<T>): 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.
|
||||
@@ -47,9 +83,15 @@ class EditorProviders {
|
||||
*/
|
||||
private languageToProvider: Map<EditorLanguage, string> = new Map();
|
||||
|
||||
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||
/**
|
||||
* Event emitter for provider registration events.
|
||||
*/
|
||||
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
|
||||
|
||||
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||
/**
|
||||
* Event emitter for provider unregistration events.
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
|
||||
@@ -27,13 +27,11 @@ 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';
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
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;
|
||||
@@ -48,19 +47,19 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerEmitter.fire(event);
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterEmitter.fire(event);
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
@@ -118,11 +117,16 @@ export const useMenu = (location: string): Menu | undefined =>
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => registerEmitter.subscribe(listener);
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
|
||||
unregisterEmitter.subscribe(listener);
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Reset module state between tests so currentPage is re-initialized.
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
async function importNavigation() {
|
||||
const mod = await import('./index');
|
||||
return mod;
|
||||
}
|
||||
|
||||
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
// The default pathname ('/') is not enumerated and falls back to home.
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
notifyPageChange('/superset/welcome/');
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
});
|
||||
|
||||
test('getPage derives the page from window.location.pathname', async () => {
|
||||
window.location.pathname = '/superset/dashboard/42/';
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPage()).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('notifyPageChange updates the current page type', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
notifyPageChange('/explore/?form_data={}');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
|
||||
test('notifyPageChange fires listeners on page type change', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
|
||||
window.location.pathname = '/superset/dashboard/1/';
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/2/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onDidChangePage listener is removed after dispose', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
disposable.dispose();
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sqllab path is matched with and without trailing slash', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyPageChange('/explore/');
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
});
|
||||
|
||||
test('chart and dashboard list pages get their own page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/list/');
|
||||
expect(navigation.getPage()).toBe('chart_list');
|
||||
notifyPageChange('/dashboard/list/');
|
||||
expect(navigation.getPage()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/tablemodelview/list/');
|
||||
expect(navigation.getPage()).toBe('dataset_list');
|
||||
notifyPageChange('/dataset/42');
|
||||
expect(navigation.getPage()).toBe('dataset');
|
||||
});
|
||||
|
||||
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyPageChange('/sqllab/history/');
|
||||
expect(navigation.getPage()).toBe('query_history');
|
||||
notifyPageChange('/savedqueryview/list/');
|
||||
expect(navigation.getPage()).toBe('saved_queries');
|
||||
});
|
||||
|
||||
test('chart/add resolves to explore, not chart_list', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/add');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `navigation` namespace.
|
||||
*
|
||||
* Backed by browser location — no Redux dependency.
|
||||
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
|
||||
*/
|
||||
|
||||
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type Page = navigationApi.Page;
|
||||
|
||||
const pageChangeEmitter = createEventEmitter<Page>();
|
||||
|
||||
function derivePage(pathname: string): Page {
|
||||
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
|
||||
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
|
||||
if (pathname.startsWith('/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||
if (pathname.startsWith('/chart/list')) return 'chart_list';
|
||||
if (pathname.startsWith('/sqllab/history')) return 'query_history';
|
||||
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
|
||||
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
|
||||
return 'sqllab';
|
||||
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
|
||||
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||
// The welcome page and any route not explicitly enumerated fall back to home.
|
||||
return 'home';
|
||||
}
|
||||
|
||||
let currentPage: Page | undefined;
|
||||
|
||||
function getOrInitPage(): Page {
|
||||
if (currentPage === undefined) {
|
||||
currentPage = derivePage(window.location.pathname);
|
||||
}
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||
export const notifyPageChange = (pathname: string): void => {
|
||||
const next = derivePage(pathname);
|
||||
if (next === getOrInitPage()) return;
|
||||
currentPage = next;
|
||||
pageChangeEmitter.fire(next);
|
||||
};
|
||||
|
||||
const getPage: typeof navigationApi.getPage = () => getOrInitPage();
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (page: Page) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPage,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -21,57 +21,6 @@ import { AnyAction } from 'redux';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
|
||||
type Listener<T> = (e: T) => unknown;
|
||||
|
||||
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
|
||||
export interface EventEmitter<T> {
|
||||
/** Notifies every current subscriber with `value`. */
|
||||
fire(value: T): void;
|
||||
/** Registers a listener; returns a Disposable that removes it. */
|
||||
subscribe: core.Event<T>;
|
||||
}
|
||||
|
||||
/** An event emitter that also retains the last fired value. */
|
||||
export interface ValueEventEmitter<T> extends EventEmitter<T> {
|
||||
/** 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<T>(): EventEmitter<T> {
|
||||
const listeners = new Set<Listener<T>>();
|
||||
const subscribe: core.Event<T> = (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<T>(initial: T): ValueEventEmitter<T> {
|
||||
const { fire, subscribe } = createEventEmitter<T>();
|
||||
let current = initial;
|
||||
return {
|
||||
fire: value => {
|
||||
current = value;
|
||||
fire(value);
|
||||
},
|
||||
subscribe,
|
||||
getCurrent: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionListener<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
listener: (v: V) => void,
|
||||
|
||||
@@ -29,7 +29,6 @@ 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;
|
||||
@@ -48,19 +47,19 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerEmitter.fire(event);
|
||||
registerListeners.forEach(l => l(event));
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterEmitter.fire(event);
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
@@ -117,11 +116,17 @@ export const useViews = (location: string): View[] | undefined =>
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => registerEmitter.subscribe(listener);
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => unregisterEmitter.subscribe(listener);
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
|
||||
@@ -45,7 +45,6 @@ import TextControl from 'src/explore/components/controls/TextControl';
|
||||
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
|
||||
import PopoverSection from '@superset-ui/core/components/PopoverSection';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import {
|
||||
ANNOTATION_SOURCE_TYPES,
|
||||
ANNOTATION_TYPES,
|
||||
@@ -146,7 +145,7 @@ const NotFoundContent = () => (
|
||||
<span>
|
||||
{t('Add an annotation layer')}{' '}
|
||||
<a
|
||||
href={ensureAppRoot('/annotationlayer/list')}
|
||||
href="/annotationlayer/list"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -31,6 +31,7 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
remoteEntry: '',
|
||||
extensionDependencies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ afterEach(() => {
|
||||
test('renders without crashing', () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -89,7 +88,6 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -97,7 +95,6 @@ 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();
|
||||
@@ -112,7 +109,6 @@ 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(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -131,7 +127,6 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -149,7 +144,6 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -175,7 +169,6 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -212,7 +205,6 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -242,7 +234,6 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -277,7 +268,6 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,78 +16,58 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { useEffect } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
import { notifyPageChange } from 'src/core/navigation';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/views/store';
|
||||
import ExtensionsLoader from './ExtensionsLoader';
|
||||
import 'src/extensions/Namespaces';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const prevPathname = useRef<string | null>(null);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
|
||||
// Notify the navigation namespace on every route change.
|
||||
useEffect(() => {
|
||||
if (prevPathname.current !== location.pathname) {
|
||||
prevPathname.current = location.pathname;
|
||||
notifyPageChange(location.pathname);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
if (userId == null) return;
|
||||
|
||||
// Log unhandled rejections that may originate from extension code.
|
||||
// Registered once for the lifetime of the app; does not suppress the
|
||||
// browser's default error surfacing so host error reporting is unaffected.
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
logging.error('[extensions] Unhandled rejection:', event.reason);
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'unhandledrejection',
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Provide the implementations for @apache-superset/core.
|
||||
// Namespaces are listed explicitly — do not spread the core package here,
|
||||
// as that would leak un-contracted symbols onto window.superset.
|
||||
// Provide the implementations for @apache-superset/core
|
||||
window.superset = {
|
||||
...supersetCore,
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
DatasetObject,
|
||||
} from 'src/features/datasets/AddDataset/types';
|
||||
import { Table } from 'src/hooks/apiResources';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
|
||||
interface LeftPanelProps {
|
||||
setDataset: Dispatch<SetStateAction<object>>;
|
||||
|
||||
@@ -218,10 +218,10 @@ export function Menu({
|
||||
const path = location.pathname;
|
||||
switch (true) {
|
||||
case path.startsWith(Paths.Dashboard):
|
||||
setActiveTabs([t('Dashboards')]);
|
||||
setActiveTabs(['Dashboards']);
|
||||
break;
|
||||
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
|
||||
setActiveTabs([t('Charts')]);
|
||||
setActiveTabs(['Charts']);
|
||||
break;
|
||||
case path.startsWith(Paths.Datasets):
|
||||
setActiveTabs([datasetsLabel()]);
|
||||
@@ -263,10 +263,9 @@ export function Menu({
|
||||
|
||||
const childItems: MenuItem[] = [];
|
||||
childs?.forEach((child: MenuObjectChildProps | string, index1: number) => {
|
||||
if (typeof child === 'string' && child === '-' && label !== t('Data')) {
|
||||
if (typeof child === 'string' && child === '-' && label !== 'Data') {
|
||||
childItems.push({ type: 'divider', key: `divider-${index1}` });
|
||||
} else if (typeof child !== 'string') {
|
||||
Object.assign(child, { label: t(child.label) });
|
||||
childItems.push({
|
||||
key: `${child.label}`,
|
||||
label: child.isFrontendRoute ? (
|
||||
@@ -367,7 +366,6 @@ export function Menu({
|
||||
items={menu.map(item => {
|
||||
const props = {
|
||||
...item,
|
||||
label: t(item.label),
|
||||
isFrontendRoute: isFrontendRoute(item.url),
|
||||
childs: item.childs?.map(c => {
|
||||
if (typeof c === 'string') {
|
||||
@@ -431,16 +429,15 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
|
||||
// Apply any label override for this item (keyed by FAB internal name).
|
||||
...(item.name && labelOverrides[item.name]
|
||||
? { label: labelOverrides[item.name]() }
|
||||
: { label: t(item.label) }),
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Filter childs
|
||||
if (item.childs) {
|
||||
item.childs.forEach((child: MenuObjectChildProps | string) => {
|
||||
if (typeof child === 'string') {
|
||||
children.push(t(child));
|
||||
children.push(child);
|
||||
} else if ((child as MenuObjectChildProps).label) {
|
||||
Object.assign(child, { label: t(child.label) });
|
||||
children.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,34 +110,6 @@ describe('getBootstrapData and helpers', () => {
|
||||
expect(staticAssetsPrefix()).toEqual(expectedStaticPrefix);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['//evil.example.com', 'protocol-relative URL'],
|
||||
// eslint-disable-next-line no-script-url -- intentional unsafe value under test
|
||||
['javascript:alert(1)', 'javascript scheme'],
|
||||
['https://evil.example.com', 'absolute URL'],
|
||||
['/foo"><img src=x>', 'path with HTML meta-characters'],
|
||||
])(
|
||||
'should fall back to the default root when application_root is %s (%s)',
|
||||
async unsafeRoot => {
|
||||
const customData = {
|
||||
common: {
|
||||
application_root: unsafeRoot,
|
||||
static_assets_prefix: '/custom-static/',
|
||||
},
|
||||
};
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
|
||||
|
||||
jest.resetModules();
|
||||
const { default: getBootstrapData, applicationRoot } =
|
||||
await import('./getBootstrapData');
|
||||
getBootstrapData();
|
||||
|
||||
const expectedAppRoot =
|
||||
DEFAULT_BOOTSTRAP_DATA.common.application_root.replace(/\/$/, '');
|
||||
expect(applicationRoot()).toEqual(expectedAppRoot);
|
||||
},
|
||||
);
|
||||
|
||||
test('should defaults without trailing slashes when #app element does not include application_root or static_assets_prefix', async () => {
|
||||
// Set up the fake #app element
|
||||
const customData = {
|
||||
|
||||
@@ -38,34 +38,7 @@ const normalizePathWithFallback = (
|
||||
fallback: string,
|
||||
): string => (path ?? fallback).replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* Matches a plain absolute path prefix (e.g. "" for root deployments or
|
||||
* "/analytics" for a subdirectory). The character after the leading slash must
|
||||
* not be another slash, so protocol-relative URLs ("//host") and scheme-bearing
|
||||
* values ("javascript:...") do not qualify.
|
||||
*/
|
||||
const SAFE_APPLICATION_ROOT_RE = /^(\/[\w\-.][\w\-./]*)?$/;
|
||||
|
||||
/**
|
||||
* The application root (SUPERSET_APP_ROOT) is reflected into links and
|
||||
* navigation, so constrain it to a plain absolute path before use. Anything
|
||||
* that isn't a simple "/path" prefix falls back to the default root so a
|
||||
* malformed value can't be reinterpreted as HTML or redirect off-origin. This
|
||||
* also keeps the bootstrap-derived value from being treated as a tainted href
|
||||
* source by static analysis.
|
||||
*/
|
||||
const sanitizeApplicationRoot = (
|
||||
path: string | undefined,
|
||||
fallback: string,
|
||||
): string => {
|
||||
const normalizedFallback = normalizePathWithFallback(fallback, fallback);
|
||||
const normalized = normalizePathWithFallback(path, fallback);
|
||||
return SAFE_APPLICATION_ROOT_RE.test(normalized)
|
||||
? normalized
|
||||
: normalizedFallback;
|
||||
};
|
||||
|
||||
const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeApplicationRoot(
|
||||
const APPLICATION_ROOT_NO_TRAILING_SLASH = normalizePathWithFallback(
|
||||
getBootstrapData().common.application_root,
|
||||
DEFAULT_BOOTSTRAP_DATA.common.application_root,
|
||||
);
|
||||
|
||||
@@ -38,9 +38,7 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
|
||||
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
|
||||
import ChatMount from 'src/components/ChatMount';
|
||||
import { RootContextProviders } from './RootContextProviders';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
|
||||
@@ -114,13 +112,6 @@ const App = () => (
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
{/*
|
||||
The singleton chat slot. Rendered as a sibling of the route
|
||||
Switch — inside ExtensionsStartup so chat extensions have been
|
||||
loaded and registered, but outside the Switch so the chat persists
|
||||
across route changes.
|
||||
*/}
|
||||
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
|
||||
@@ -39,14 +39,14 @@ logger = logging.getLogger(__name__)
|
||||
@click.command()
|
||||
@with_appcontext
|
||||
@transaction()
|
||||
@click.option("--database_name", "-d", required=True, help="Database name to change")
|
||||
@click.option("--uri", "-u", required=True, help="Database URI to change")
|
||||
@click.option("--database_name", "-d", help="Database name to change")
|
||||
@click.option("--uri", "-u", help="Database URI to change")
|
||||
@click.option(
|
||||
"--skip_create",
|
||||
"-s",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Don't create the DB if it doesn't exist",
|
||||
help="Create the DB if it doesn't exist",
|
||||
)
|
||||
def set_database_uri(database_name: str, uri: str, skip_create: bool) -> None:
|
||||
"""Updates a database connection URI"""
|
||||
|
||||
@@ -78,7 +78,7 @@ class ExportChartsCommand(ExportModelsCommand):
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
tags = getattr(model, "tags", [])
|
||||
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
return file_content
|
||||
|
||||
_include_tags: bool = True # Default to True
|
||||
|
||||
@@ -202,7 +202,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
tags = model.tags if hasattr(model, "tags") else []
|
||||
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
|
||||
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
return file_content
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -108,7 +108,7 @@ class ExportDatabasesCommand(ExportModelsCommand):
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
return file_content
|
||||
|
||||
@staticmethod
|
||||
@@ -140,9 +140,6 @@ class ExportDatabasesCommand(ExportModelsCommand):
|
||||
yield (
|
||||
file_path,
|
||||
functools.partial( # type: ignore
|
||||
yaml.safe_dump,
|
||||
payload,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
yaml.safe_dump, payload, sort_keys=False
|
||||
),
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
# serialize. Convert all keys to regular strings to fix YAML serialization.
|
||||
payload = {str(key): value for key, value in payload.items()}
|
||||
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
return file_content
|
||||
|
||||
@staticmethod
|
||||
@@ -128,7 +128,4 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
yield (
|
||||
file_path,
|
||||
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
|
||||
)
|
||||
yield file_path, lambda: yaml.safe_dump(payload, sort_keys=False)
|
||||
|
||||
@@ -36,10 +36,10 @@ class OracleEngineSpec(BaseEngineSpec):
|
||||
DatabaseCategory.PROPRIETARY,
|
||||
],
|
||||
"pypi_packages": ["oracledb"],
|
||||
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
|
||||
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
|
||||
"default_port": 1521,
|
||||
"notes": "Previously used cx_Oracle, now uses oracledb.",
|
||||
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
|
||||
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
|
||||
}
|
||||
force_column_alias_quotes = True
|
||||
max_column_name_length = 128
|
||||
|
||||
@@ -238,7 +238,6 @@ 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 "",
|
||||
|
||||
@@ -592,84 +592,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
This class is used for all engines with dialects that can be parsed using sqlglot.
|
||||
"""
|
||||
|
||||
# Function names that mutate server-side state but appear in the AST as
|
||||
# plain function calls inside a non-mutating wrapper. Used by
|
||||
# ``is_mutating()`` to classify e.g. PostgreSQL large-object writers.
|
||||
# Names are uppercased for comparison.
|
||||
_MUTATING_FUNCTION_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"LO_FROM_BYTEA",
|
||||
"LO_EXPORT",
|
||||
"LO_IMPORT",
|
||||
"LO_PUT",
|
||||
"LO_CREATE",
|
||||
"LOWRITE",
|
||||
"LO_UNLINK",
|
||||
# PostgreSQL sequence mutators. `SELECT setval('seq', N)` and
|
||||
# `SELECT nextval('seq')` look like reads but change sequence state
|
||||
# for every subsequent caller. (`currval` only reads the session's
|
||||
# last value, so it is intentionally not listed.)
|
||||
"SETVAL",
|
||||
"NEXTVAL",
|
||||
}
|
||||
)
|
||||
|
||||
# PostgreSQL constructs that sqlglot represents as an opaque ``exp.Command``
|
||||
# (no structured AST). Each can mutate server state or wrap a DML body that
|
||||
# would otherwise be detected by node-type matching. Used by
|
||||
# ``is_mutating()``.
|
||||
_POSTGRES_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"DO", # PL/pgSQL anonymous block
|
||||
"PREPARE", # PREPARE u AS UPDATE ... ; EXECUTE u
|
||||
"EXECUTE", # body is the prepared DML
|
||||
"CALL", # procedure body may mutate
|
||||
"COPY", # server-side file ingest into a table
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
# Only the command-fallback forms (e.g. SET ROLE / SET SESSION
|
||||
# AUTHORIZATION, which change the effective user) reach here as an
|
||||
# exp.Command. Structured `SET search_path = ...` /
|
||||
# `SET statement_timeout = ...` parse as exp.Set and are NOT matched
|
||||
# by this command-name path.
|
||||
"SET",
|
||||
"RESET", # RESET ROLE / RESET ALL reverts SET; same class as SET
|
||||
"REFRESH", # REFRESH MATERIALIZED VIEW
|
||||
"REINDEX",
|
||||
"VACUUM",
|
||||
# DDL head-tokens that sqlglot falls back to exp.Command for
|
||||
# whenever the body uses syntax it does not model
|
||||
# (CREATE EXTENSION/FUNCTION...LANGUAGE C/PUBLICATION/etc.,
|
||||
# ALTER ROLE/SYSTEM/..., DROP EXTENSION/RULE/...). Well-formed
|
||||
# CREATE TABLE/ALTER TABLE/DROP TABLE are already caught by the
|
||||
# node-type tuple; these entries close the fallback path.
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"DROP",
|
||||
"LOAD", # LOAD '/path/lib.so' dlopens a shared library on the PG host
|
||||
# NOTE: `SHOW` is intentionally NOT included. It is a read (mutates
|
||||
# nothing), so classifying it as mutating would be wrong for every
|
||||
# is_mutating()/has_mutation() consumer (the commit decision, the
|
||||
# "only SELECT allowed" validators, limit handling), not just the
|
||||
# read-only gate. Gating information-disclosure reads such as
|
||||
# `SHOW server_version` belongs in a denylist (DISALLOWED_SQL_FUNCTIONS
|
||||
# already blocks version()/pg_read_file), not in the mutation check.
|
||||
}
|
||||
)
|
||||
|
||||
# Dialects where `SELECT ... INTO target` is CTAS (creates a table, and so
|
||||
# mutates schema). Elsewhere the same syntax assigns into a variable and is
|
||||
# a read: Oracle PL/SQL `SELECT ... INTO v` and MySQL `SELECT ... INTO @v`
|
||||
# parse into an identical `exp.Select` with an `into` arg, so the dialect is
|
||||
# the only signal that distinguishes the mutating form from the read form.
|
||||
_SELECT_INTO_CTAS_DIALECTS: frozenset[Dialects] = frozenset(
|
||||
{
|
||||
Dialects.POSTGRES,
|
||||
Dialects.REDSHIFT,
|
||||
Dialects.TSQL,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
statement: str | None = None,
|
||||
@@ -803,67 +725,25 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
exp.Drop,
|
||||
exp.TruncateTable,
|
||||
exp.Alter,
|
||||
# sqlglot has structured nodes for these DML/DCL forms in
|
||||
# PostgreSQL and other dialects; without them an opaque exp.Command
|
||||
# check would still miss the structured-parse path.
|
||||
exp.Copy, # COPY <table> FROM/TO (server-side file ingest)
|
||||
exp.Grant,
|
||||
exp.Revoke,
|
||||
# COMMENT ON TABLE/COLUMN/etc. writes to system catalog pg_description.
|
||||
exp.Comment,
|
||||
)
|
||||
|
||||
if self._parsed.find(*mutating_nodes):
|
||||
return True
|
||||
|
||||
# `SELECT ... INTO new_table FROM ...` parses as `exp.Select` with an
|
||||
# `into` arg (Postgres-style CTAS variant). It creates a new table and
|
||||
# therefore mutates schema. Only treat it as mutating for dialects where
|
||||
# the syntax is CTAS; elsewhere it assigns into a variable (a read).
|
||||
if (
|
||||
self._dialect in self._SELECT_INTO_CTAS_DIALECTS
|
||||
and isinstance(self._parsed, exp.Select)
|
||||
and self._parsed.args.get("into")
|
||||
):
|
||||
return True
|
||||
|
||||
# Function calls that mutate server-side state without an enclosing
|
||||
# mutating AST node. Notable example: PostgreSQL large-object writers
|
||||
# (`lo_export` writes to the server filesystem, `lo_from_bytea`/
|
||||
# `lo_create`/`lo_put`/`lo_import`/`lowrite` mutate the pg_largeobject
|
||||
# catalog). These appear as plain function calls inside an `exp.Select`
|
||||
# and would otherwise pass the read-only gate. Every name in
|
||||
# _MUTATING_FUNCTION_NAMES is PostgreSQL-specific, so the walk is gated
|
||||
# on the dialect: other engines may expose read-only functions/UDFs with
|
||||
# the same names, and flagging those would wrongly block read-only
|
||||
# queries. Each parses as an `exp.Anonymous`, whose `.name` is the bare
|
||||
# function identifier. The walk is restricted to `exp.Anonymous` rather
|
||||
# than the broader `exp.Func`, because for built-in function nodes (e.g.
|
||||
# `exp.Upper`) `.name` returns the first argument's text, not the
|
||||
# function name, so `SELECT upper('lo_export')` would otherwise be
|
||||
# misclassified as mutating.
|
||||
if self._dialect == Dialects.POSTGRES and any(
|
||||
function.name.upper() in self._MUTATING_FUNCTION_NAMES
|
||||
for function in self._parsed.find_all(exp.Anonymous)
|
||||
):
|
||||
return True
|
||||
for node_type in mutating_nodes:
|
||||
if self._parsed.find(node_type):
|
||||
return True
|
||||
|
||||
# depending on the dialect (Oracle, MS SQL) the `ALTER` is parsed as a
|
||||
# command, not an expression - check at root level
|
||||
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
|
||||
return True # pragma: no cover
|
||||
|
||||
# PostgreSQL constructs that sqlglot represents as an opaque
|
||||
# `exp.Command` rather than a structured AST. Each of these can mutate
|
||||
# state or wrap a DML body that would otherwise be detected. The
|
||||
# `.name` attribute on `exp.Command` preserves the source-case of the
|
||||
# head keyword (so `create extension ...` would yield `'create'`),
|
||||
# which means the set lookup must be case-insensitive.
|
||||
if (
|
||||
self._dialect == Dialects.POSTGRES
|
||||
and isinstance(self._parsed, exp.Command)
|
||||
and self._parsed.name.upper() in self._POSTGRES_MUTATING_COMMAND_NAMES
|
||||
and self._parsed.name == "DO"
|
||||
):
|
||||
# anonymous blocks can be written in many different languages (the default
|
||||
# is PL/pgSQL), so parsing them it out of scope of this class; we just
|
||||
# assume the anonymous block is mutating
|
||||
return True
|
||||
|
||||
# Postgres runs DMLs prefixed by `EXPLAIN ANALYZE`, see
|
||||
@@ -984,13 +864,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
else:
|
||||
present.add(function.name.upper())
|
||||
|
||||
# MySQL `@@<name>` syntax (also Oracle/SQL-Server `@@name`) parses as
|
||||
# `exp.SessionParameter`, which is *not* a subclass of `exp.Func`, so
|
||||
# the walk above misses it. Include those names so denylist entries
|
||||
# like `version` or `hostname` match `SELECT @@version`.
|
||||
for param in self._parsed.find_all(exp.SessionParameter):
|
||||
present.add(param.name.upper())
|
||||
|
||||
return any(function.upper() in present for function in functions)
|
||||
|
||||
def check_tables_present(self, tables: set[str]) -> bool:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,33 +178,6 @@ class TestExportChartsCommand(SupersetTestCase):
|
||||
]
|
||||
assert expected == list(contents.keys())
|
||||
|
||||
@patch("superset.security.manager.g")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_export_chart_command_unicode_chars(self, mock_g):
|
||||
"""Test that unicode characters in a chart name are exported to the YAML"""
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
db.session.query(Slice).filter_by(slice_name="Energy Sankey").update(
|
||||
{"slice_name": "中文"},
|
||||
)
|
||||
try:
|
||||
example_chart = db.session.query(Slice).filter_by(slice_name="中文").one()
|
||||
|
||||
command = ExportChartsCommand([example_chart.id])
|
||||
contents = dict(command.run())
|
||||
|
||||
path = f"charts/{example_chart.id}.yaml"
|
||||
assert path in set(contents.keys())
|
||||
yaml_content = contents[path]()
|
||||
metadata = yaml.safe_load(yaml_content)
|
||||
assert metadata["slice_name"] == "中文"
|
||||
assert "slice_name: 中文" in yaml_content
|
||||
finally:
|
||||
# restore the original name so fixture teardown works even if an
|
||||
# assertion above fails
|
||||
db.session.query(Slice).filter_by(slice_name="中文").update(
|
||||
{"slice_name": "Energy Sankey"},
|
||||
)
|
||||
|
||||
|
||||
class TestImportChartsCommand(SupersetTestCase):
|
||||
@patch("superset.utils.core.g")
|
||||
|
||||
@@ -507,36 +507,6 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
}
|
||||
assert expected_paths == set(contents.keys())
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@patch("superset.security.manager.g")
|
||||
@patch("superset.views.base.g")
|
||||
def test_export_dashboard_command_unicode_chars(self, mock_g1, mock_g2):
|
||||
mock_g1.user = security_manager.find_user("admin")
|
||||
mock_g2.user = security_manager.find_user("admin")
|
||||
db.session.query(Dashboard).filter_by(slug="world_health").update(
|
||||
{"dashboard_title": "中文"},
|
||||
)
|
||||
try:
|
||||
example_dashboard = (
|
||||
db.session.query(Dashboard).filter_by(dashboard_title="中文").one()
|
||||
)
|
||||
|
||||
command = ExportDashboardsCommand([example_dashboard.id])
|
||||
contents = dict(command.run())
|
||||
|
||||
path = f"dashboards/{example_dashboard.id}.yaml"
|
||||
assert path in set(contents.keys())
|
||||
yaml_content = contents[path]()
|
||||
metadata = yaml.safe_load(yaml_content)
|
||||
assert metadata["dashboard_title"] == "中文"
|
||||
assert "dashboard_title: 中文" in yaml_content
|
||||
finally:
|
||||
# restore the shared fixture title so later tests that rely on it
|
||||
# (e.g. test_export_dashboard_command_no_related) are not affected
|
||||
db.session.query(Dashboard).filter_by(slug="world_health").update(
|
||||
{"dashboard_title": "World Bank's Data"},
|
||||
)
|
||||
|
||||
|
||||
class TestImportDashboardsCommand(SupersetTestCase):
|
||||
def test_import_v0_dashboard_cli_export(self):
|
||||
|
||||
@@ -395,30 +395,6 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
assert "databases" in prefixes
|
||||
assert "datasets" not in prefixes
|
||||
|
||||
@patch("superset.security.manager.g")
|
||||
def test_export_database_command_unicode_chars(self, mock_g):
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
db.session.query(Database).filter_by(database_name="中文").delete()
|
||||
db.session.commit()
|
||||
command = CreateDatabaseCommand(
|
||||
{"database_name": "中文", "sqlalchemy_uri": "sqlite:///:memory:"},
|
||||
)
|
||||
example_db = command.run()
|
||||
|
||||
try:
|
||||
command = ExportDatabasesCommand([example_db.id], export_related=False)
|
||||
contents = dict(command.run())
|
||||
|
||||
path = f"databases/{example_db.id}.yaml"
|
||||
assert path in set(contents.keys())
|
||||
yaml_content = contents[path]()
|
||||
assert "database_name: 中文" in yaml_content
|
||||
finally:
|
||||
# CreateDatabaseCommand commits the new database, so the cleanup must
|
||||
# also be committed and must run even if an assertion above fails
|
||||
db.session.query(Database).filter_by(database_name="中文").delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestImportDatabasesCommand(SupersetTestCase):
|
||||
@patch("superset.security.manager.g")
|
||||
|
||||
@@ -273,42 +273,6 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
f"datasets/examples/energy_usage_{example_dataset.id}.yaml",
|
||||
]
|
||||
|
||||
@patch("superset.security.manager.g")
|
||||
def test_export_dataset_command_unicode_chars(self, mock_g) -> None:
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
examples_db = get_example_database()
|
||||
with examples_db.get_sqla_engine() as engine:
|
||||
engine.execute("DROP TABLE IF EXISTS 中文")
|
||||
engine.execute("CREATE TABLE 中文 AS SELECT 2 as col")
|
||||
# scope cleanup to the example database so datasets with the same name
|
||||
# on other databases are left untouched
|
||||
stale = db.session.query(SqlaTable).filter_by(
|
||||
table_name="中文", database_id=examples_db.id
|
||||
)
|
||||
if stale.count():
|
||||
stale.delete()
|
||||
with override_user(security_manager.find_user("admin")):
|
||||
example_dataset = CreateDatasetCommand(
|
||||
{
|
||||
"table_name": "中文",
|
||||
"database": examples_db.id,
|
||||
}
|
||||
).run()
|
||||
|
||||
command = ExportDatasetsCommand([example_dataset.id], export_related=False)
|
||||
contents = dict(command.run())
|
||||
|
||||
path = f"datasets/examples/{example_dataset.id}.yaml"
|
||||
assert path in set(contents.keys())
|
||||
yaml_content = contents[path]()
|
||||
assert "table_name: 中文" in yaml_content
|
||||
|
||||
db.session.delete(example_dataset)
|
||||
db.session.commit()
|
||||
with examples_db.get_sqla_engine() as engine:
|
||||
engine.execute("DROP TABLE 中文")
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestImportDatasetsCommand(SupersetTestCase):
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@@ -638,7 +602,6 @@ class TestCreateDatasetCommand(SupersetTestCase):
|
||||
assert [owner.username for owner in table.owners] == ["admin"]
|
||||
|
||||
db.session.delete(table)
|
||||
db.session.commit()
|
||||
with examples_db.get_sqla_engine() as engine:
|
||||
engine.execute("DROP TABLE test_create_dataset_command")
|
||||
db.session.commit()
|
||||
|
||||
@@ -1143,14 +1143,7 @@ def test_split_kql(kql: str, expected: list[str]) -> None:
|
||||
("postgresql", "DROP TABLE foo", True),
|
||||
("postgresql", "EXPLAIN ANALYZE SELECT * FROM foo", False),
|
||||
("postgresql", "EXPLAIN ANALYZE DELETE FROM foo", True),
|
||||
# SHOW reads server configuration; it mutates nothing, so it is NOT
|
||||
# classified as mutating (that would be wrong for the commit/limit/
|
||||
# "only SELECT" consumers of has_mutation()). Gating disclosure reads
|
||||
# belongs in DISALLOWED_SQL_FUNCTIONS, not the mutation check.
|
||||
("postgresql", "SHOW search_path", False),
|
||||
# SET search_path parses as exp.Set (a structured node), not
|
||||
# exp.Command, so the SET-in-mutating-commands rule does NOT
|
||||
# catch it. Pure GUC reads/writes stay non-mutating.
|
||||
("postgresql", "SET search_path TO public", False),
|
||||
(
|
||||
"postgres",
|
||||
@@ -1395,9 +1388,6 @@ def test_custom_dialect(app: None) -> None:
|
||||
("SELECT 1", False),
|
||||
("with source as ( select 1 as one ) select * from source", False),
|
||||
("ALTER TABLE foo ADD COLUMN bar INT", True),
|
||||
# COMMENT ON parses as a typed exp.Comment node across dialects; it
|
||||
# writes to the catalog (pg_description on Postgres) so it is gated.
|
||||
("COMMENT ON TABLE t IS 'note'", True),
|
||||
],
|
||||
)
|
||||
def test_is_mutating(sql: str, engine: str, expected: bool) -> None:
|
||||
@@ -1444,222 +1434,6 @@ def test_is_mutating_anonymous_block(sql: str, expected: bool) -> None:
|
||||
assert SQLStatement(sql, "postgresql").is_mutating() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
# PostgreSQL large-object writers: each mutates server state. The bare
|
||||
# SELECT wrapper is irrelevant because the function call itself is the
|
||||
# side effect.
|
||||
("SELECT lo_from_bytea(0, decode('deadbeef', 'hex'))", True),
|
||||
("SELECT lo_export(12345, '/tmp/payload.bin')", True),
|
||||
("SELECT lo_import('/etc/passwd')", True),
|
||||
("SELECT lo_put(12345, 0, decode('00', 'hex'))", True),
|
||||
("SELECT lo_create(0)", True),
|
||||
("SELECT lowrite(12345, decode('00', 'hex'))", True),
|
||||
# lo_unlink deletes a large object outright.
|
||||
("SELECT lo_unlink(12345)", True),
|
||||
# PostgreSQL sequence mutators. setval()/nextval() look like reads but
|
||||
# advance sequence state for every subsequent caller.
|
||||
("SELECT setval('public.my_seq', 1000)", True),
|
||||
("SELECT SETVAL('public.my_seq', 1)", True),
|
||||
("SELECT nextval('public.my_seq')", True),
|
||||
# currval() only reads the session's last value, so it is not mutating.
|
||||
("SELECT currval('public.my_seq')", False),
|
||||
# Read-side large-object functions are intentionally NOT classified
|
||||
# as mutating here. They are still blocked via the function denylist
|
||||
# (see DISALLOWED_SQL_FUNCTIONS) but they do not write state.
|
||||
("SELECT lo_get(12345)", False),
|
||||
("SELECT loread(12345, 1024)", False),
|
||||
# Case-insensitive matching: the AST stores the raw casing for
|
||||
# anonymous functions, the check uppercases both sides.
|
||||
("SELECT LO_EXPORT(12345, '/tmp/x')", True),
|
||||
# `SELECT INTO new_table FROM existing` creates a new relation; treat
|
||||
# as mutating even though sqlglot parses it as exp.Select.
|
||||
("SELECT * INTO new_table FROM existing_table", True),
|
||||
("SELECT col INTO TEMP new_table FROM existing_table", True),
|
||||
# A built-in function whose first string argument happens to match a
|
||||
# mutating name must NOT be flagged. sqlglot parses these into dedicated
|
||||
# nodes (e.g. exp.Upper) whose `.name` is the argument text, not the
|
||||
# function name, so the walk is restricted to exp.Anonymous to avoid a
|
||||
# false positive on this read-only query.
|
||||
("SELECT upper('lo_export')", False),
|
||||
("SELECT length('setval')", False),
|
||||
# Plain SELECT must remain non-mutating.
|
||||
("SELECT 1", False),
|
||||
("SELECT * FROM users WHERE id = 1", False),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_postgres_function_and_select_into(
|
||||
sql: str, expected: bool
|
||||
) -> None:
|
||||
"""
|
||||
`is_mutating` must catch mutating function calls (PostgreSQL large-object
|
||||
writers) and `SELECT ... INTO new_table` even though the wrapping AST
|
||||
node is a plain `exp.Select`.
|
||||
"""
|
||||
assert SQLStatement(sql, "postgresql").is_mutating() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"engine, sql",
|
||||
[
|
||||
# `SELECT ... INTO new_table` is CTAS only in Postgres/Redshift/T-SQL.
|
||||
# In Oracle PL/SQL and MySQL the same syntax assigns into a variable
|
||||
# and is a read, so it must NOT be classified as mutating.
|
||||
("oracle", "SELECT col INTO v FROM existing_table"),
|
||||
("mysql", "SELECT col INTO @v FROM existing_table"),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_select_into_variable_is_read(engine: str, sql: str) -> None:
|
||||
"""
|
||||
`SELECT ... INTO target` is only CTAS (mutating) for dialects where the
|
||||
syntax creates a table. On Oracle/MySQL it assigns into a variable and is
|
||||
a read, so `is_mutating` must return False there.
|
||||
"""
|
||||
assert SQLStatement(sql, engine).is_mutating() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"engine, sql",
|
||||
[
|
||||
# `SELECT ... INTO new_table` is CTAS on Redshift and T-SQL just as it
|
||||
# is on Postgres, so each dialect in _SELECT_INTO_CTAS_DIALECTS must
|
||||
# classify the statement as mutating.
|
||||
("redshift", "SELECT * INTO new_table FROM existing_table"),
|
||||
("redshift", "SELECT col INTO new_table FROM existing_table"),
|
||||
("mssql", "SELECT * INTO new_table FROM existing_table"),
|
||||
("mssql", "SELECT col INTO new_table FROM existing_table"),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_select_into_ctas_dialects(engine: str, sql: str) -> None:
|
||||
"""
|
||||
`SELECT ... INTO new_table` creates a table on the CTAS dialects beyond
|
||||
Postgres (Redshift, T-SQL), so `is_mutating` must return True there.
|
||||
"""
|
||||
assert SQLStatement(sql, engine).is_mutating() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"engine, sql",
|
||||
[
|
||||
# The mutating-function names are PostgreSQL built-ins. On other engines
|
||||
# a same-named read-only function or UDF must NOT be flagged as
|
||||
# mutating, otherwise read-only queries get wrongly blocked.
|
||||
("mysql", "SELECT setval(my_col)"),
|
||||
("mysql", "SELECT lo_export(id, path) FROM t"),
|
||||
("base", "SELECT setval(my_col)"),
|
||||
("trino", "SELECT lowrite(x)"),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_function_names_scoped_to_postgres(engine: str, sql: str) -> None:
|
||||
"""
|
||||
`_MUTATING_FUNCTION_NAMES` is PostgreSQL-specific, so the function-name walk
|
||||
only runs for the Postgres dialect; same-named functions on other engines
|
||||
must stay non-mutating.
|
||||
"""
|
||||
assert SQLStatement(sql, engine).is_mutating() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
# PostgreSQL constructs that sqlglot parses as opaque exp.Command.
|
||||
# Each can wrap a DML body or change effective server state.
|
||||
("PREPARE u AS UPDATE t SET x = 1", True),
|
||||
("PREPARE i AS INSERT INTO t VALUES (1)", True),
|
||||
("EXECUTE my_plan", True),
|
||||
("CALL my_writing_procedure()", True),
|
||||
("COPY t FROM '/tmp/data.csv'", True),
|
||||
("GRANT SELECT ON t TO public", True),
|
||||
("REVOKE SELECT ON t FROM public", True),
|
||||
("SET ROLE other_role", True),
|
||||
("REFRESH MATERIALIZED VIEW mv", True),
|
||||
("REINDEX TABLE t", True),
|
||||
("VACUUM t", True),
|
||||
# SHOW commands are reads (they mutate nothing), so they are NOT
|
||||
# classified as mutating. Gating information-disclosure reads such as
|
||||
# SHOW server_version belongs in DISALLOWED_SQL_FUNCTIONS (which already
|
||||
# blocks pg_read_file, version(), etc.), not in the mutation check.
|
||||
("SHOW search_path", False),
|
||||
("SHOW all", False),
|
||||
("SHOW server_version", False),
|
||||
# RESET reverts a prior SET (e.g. RESET ROLE backs out SET ROLE).
|
||||
("RESET ROLE", True),
|
||||
# DDL head-tokens that sqlglot falls back to exp.Command for when the
|
||||
# body uses syntax it does not model. One representative per
|
||||
# head-token branch (CREATE/ALTER/DROP); they all hit the same
|
||||
# set-lookup so additional CREATE PUBLICATION/SUBSCRIPTION/etc.
|
||||
# cases would not add coverage.
|
||||
(
|
||||
"CREATE FUNCTION x() RETURNS int AS '/tmp/x.so', 'i' LANGUAGE C",
|
||||
True,
|
||||
),
|
||||
("CREATE EXTENSION pg_trgm", True), # non-FUNCTION DDL via Command
|
||||
("ALTER SYSTEM SET wal_level = 'logical'", True),
|
||||
("DROP EXTENSION pg_trgm", True),
|
||||
# LOAD dlopens a shared library on the PG host. Same RCE primitive
|
||||
# as `CREATE FUNCTION ... LANGUAGE C` if the library path is
|
||||
# attacker-controlled (e.g. via a prior COPY-to-program foothold).
|
||||
("LOAD '/tmp/x.so'", True),
|
||||
# Case-insensitive: sqlglot preserves source case on Command.name,
|
||||
# so the set lookup must normalise. Regression for the original
|
||||
# bug where a lowercase head-token bypassed the gate.
|
||||
("create extension pg_trgm", True),
|
||||
("load '/tmp/x.so'", True),
|
||||
# Pre-existing positive controls
|
||||
("DO $$ BEGIN UPDATE t SET x = 1; END $$", True),
|
||||
("EXPLAIN ANALYZE UPDATE t SET x = 1", True),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_postgres_command_constructs(sql: str, expected: bool) -> None:
|
||||
"""
|
||||
Several PostgreSQL constructs are represented by sqlglot as opaque
|
||||
`exp.Command` nodes (no structured AST). `is_mutating` recognises them
|
||||
by command name so they cannot slip past the read-only gate.
|
||||
"""
|
||||
assert SQLStatement(sql, "postgresql").is_mutating() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, engine, functions, expected",
|
||||
[
|
||||
# MySQL `@@<name>` syntax parses as exp.SessionParameter, which is
|
||||
# not a subclass of exp.Func. The walker must include it so the
|
||||
# denylist entry for `version` still catches `SELECT @@version`.
|
||||
("SELECT @@version", "mysql", {"version"}, True),
|
||||
("SELECT @@global.version", "mysql", {"version"}, True),
|
||||
("SELECT @@hostname", "mysql", {"hostname"}, True),
|
||||
("SELECT @@datadir", "mysql", {"datadir"}, True),
|
||||
# Negative control: a session parameter not in the denylist must
|
||||
# not match.
|
||||
("SELECT @@autocommit", "mysql", {"version", "hostname"}, False),
|
||||
# A plain SELECT does not introduce session-parameter names.
|
||||
("SELECT 1", "mysql", {"version"}, False),
|
||||
# The pre-existing exp.Func walk still works for normal calls.
|
||||
("SELECT version()", "mysql", {"version"}, True),
|
||||
# PostgreSQL large-object functions are exp.Anonymous calls. The
|
||||
# walk includes them; the denylist entry catches them.
|
||||
("SELECT lo_export(12345, '/tmp/x')", "postgresql", {"lo_export"}, True),
|
||||
(
|
||||
"SELECT lo_from_bytea(0, decode('00','hex'))",
|
||||
"postgresql",
|
||||
{"lo_from_bytea"},
|
||||
True,
|
||||
),
|
||||
("SELECT loread(12345, 1024)", "postgresql", {"loread"}, True),
|
||||
],
|
||||
)
|
||||
def test_check_functions_present_session_parameter(
|
||||
sql: str, engine: str, functions: set[str], expected: bool
|
||||
) -> None:
|
||||
"""
|
||||
`check_functions_present` must visit `exp.SessionParameter` so that
|
||||
denylist entries for names like `version` or `hostname` also match
|
||||
`SELECT @@version` / `SELECT @@hostname` in MySQL.
|
||||
"""
|
||||
assert SQLScript(sql, engine).check_functions_present(functions) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user