diff --git a/docs/developer_portal/extensions/contribution-types.md b/docs/developer_portal/extensions/contribution-types.md index c8943bb568ba..7beb5f7ecec1 100644 --- a/docs/developer_portal/extensions/contribution-types.md +++ b/docs/developer_portal/extensions/contribution-types.md @@ -34,7 +34,7 @@ Frontend contribution types allow extensions to extend Superset's user interface Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Each view is registered with a unique ID and can be activated or deactivated as needed. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application. -``` json +```json "frontend": { "contributions": { "views": { @@ -53,7 +53,7 @@ Extensions can add new views or panels to the host application, such as custom S Extensions can define custom commands that can be executed within the host application, such as context-aware actions or menu options. Each command can specify properties like a unique command identifier, an icon, a title, and a description. These commands can be invoked by users through menus, keyboard shortcuts, or other UI elements, enabling extensions to add rich, interactive functionality to Superset. -``` json +```json "frontend": { "contributions": { "commands": [ @@ -72,7 +72,7 @@ Extensions can define custom commands that can be executed within the host appli Extensions can contribute new menu items or context menus to the host application, providing users with additional actions and options. Each menu item can specify properties such as the target view, the command to execute, its placement (primary, secondary, or context), and conditions for when it should be displayed. Menu contribution areas are uniquely identified (e.g., `sqllab.editor` for the SQL Lab editor), allowing extensions to seamlessly integrate their functionality into specific menus and workflows within Superset. -``` json +```json "frontend": { "contributions": { "menus": { @@ -101,6 +101,27 @@ Extensions can contribute new menu items or context menus to the host applicatio } ``` +### Editors + +Extensions can replace Superset's default text editors with custom implementations. This enables enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. When an extension registers an editor for a language, it replaces the default Ace editor in all locations that use that language (SQL Lab, Dashboard Properties, CSS editors, etc.). + +```json +"frontend": { + "contributions": { + "editors": [ + { + "id": "my_extension.monaco_sql", + "name": "Monaco SQL Editor", + "languages": ["sql"], + "description": "Monaco-based SQL editor with IntelliSense" + } + ] + } +} +``` + +See [Editors Extension Point](./extension-points/editors) for implementation details. + ## Backend Backend contribution types allow extensions to extend Superset's server-side capabilities with new API endpoints, MCP tools, and MCP prompts. @@ -109,7 +130,7 @@ Backend contribution types allow extensions to extend Superset's server-side cap Extensions can register custom REST API endpoints under the `/api/v1/extensions/` namespace. This dedicated namespace prevents conflicts with built-in endpoints and provides a clear separation between core and extension functionality. -``` json +```json "backend": { "entryPoints": ["my_extension.entrypoint"], "files": ["backend/src/my_extension/**/*.py"] @@ -118,7 +139,7 @@ Extensions can register custom REST API endpoints under the `/api/v1/extensions/ The entry point module registers the API with Superset: -``` python +```python from superset_core.api.rest_api import add_extension_api from .api import MyExtensionAPI diff --git a/docs/developer_portal/extensions/extension-points/editors.md b/docs/developer_portal/extensions/extension-points/editors.md new file mode 100644 index 000000000000..4cc1da5d4c17 --- /dev/null +++ b/docs/developer_portal/extensions/extension-points/editors.md @@ -0,0 +1,245 @@ +--- +title: Editors +sidebar_position: 2 +--- + + + +# Editor Contributions + +Extensions can replace Superset's default text editors with custom implementations. This allows you to provide enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. + +## Overview + +Superset uses text editors in various places throughout the application: + +| Language | Locations | +|----------|-----------| +| `sql` | SQL Lab, Metric/Filter Popovers | +| `json` | Dashboard Properties, Annotation Modal, Theme Modal | +| `css` | Dashboard Properties, CSS Template Modal | +| `markdown` | Dashboard Markdown component | +| `yaml` | Template Params Editor | + +By registering an editor provider for a language, your extension replaces the default Ace editor in **all** locations that use that language. + +## Manifest Configuration + +Declare editor contributions in your `extension.json` manifest: + +```json +{ + "name": "monaco-editor", + "version": "1.0.0", + "frontend": { + "contributions": { + "editors": [ + { + "id": "monaco-editor.sql", + "name": "Monaco SQL Editor", + "languages": ["sql"], + "description": "Monaco-based SQL editor with IntelliSense" + } + ] + } + } +} +``` + +## Implementing an Editor + +Your editor component must implement the `EditorProps` interface and expose an `EditorHandle` via `forwardRef`. For the complete interface definitions, see `@apache-superset/core/api/editors.ts`. + +### Key EditorProps + +```typescript +interface EditorProps { + /** Controlled value */ + value: string; + /** Content change handler */ + onChange: (value: string) => void; + /** Language mode for syntax highlighting */ + language: EditorLanguage; + /** Keyboard shortcuts to register */ + hotkeys?: EditorHotkey[]; + /** Callback when editor is ready with imperative handle */ + onReady?: (handle: EditorHandle) => void; + /** Host-specific context (e.g., database info from SQL Lab) */ + metadata?: Record; + // ... additional props for styling, annotations, etc. +} +``` + +### Key EditorHandle Methods + +```typescript +interface EditorHandle { + /** Focus the editor */ + focus(): void; + /** Get the current editor content */ + getValue(): string; + /** Get the current cursor position */ + getCursorPosition(): Position; + /** Move the cursor to a specific position */ + moveCursorToPosition(position: Position): void; + /** Set the selection range */ + setSelection(selection: Range): void; + /** Scroll to a specific line */ + scrollToLine(line: number): void; + // ... additional methods for text manipulation, annotations, etc. +} +``` + +## Example Implementation + +Here's an example of a Monaco-based SQL editor implementing the key interfaces shown above: + +### MonacoSQLEditor.tsx + +```typescript +import { forwardRef, useRef, useImperativeHandle, useEffect } from 'react'; +import * as monaco from 'monaco-editor'; +import type { editors } from '@apache-superset/core'; + +const MonacoSQLEditor = forwardRef( + (props, ref) => { + const { value, onChange, hotkeys, onReady } = props; + const containerRef = useRef(null); + const editorRef = useRef(null); + + // Implement EditorHandle interface + const handle: editors.EditorHandle = { + focus: () => editorRef.current?.focus(), + getValue: () => editorRef.current?.getValue() ?? '', + getCursorPosition: () => { + const pos = editorRef.current?.getPosition(); + return { line: (pos?.lineNumber ?? 1) - 1, column: (pos?.column ?? 1) - 1 }; + }, + // ... implement remaining methods + }; + + useImperativeHandle(ref, () => handle, []); + + useEffect(() => { + if (!containerRef.current) return; + + const editor = monaco.editor.create(containerRef.current, { value, language: 'sql' }); + editorRef.current = editor; + + editor.onDidChangeModelContent(() => onChange(editor.getValue())); + + // Register hotkeys + hotkeys?.forEach(hotkey => { + editor.addAction({ + id: hotkey.name, + label: hotkey.name, + run: () => hotkey.exec(handle), + }); + }); + + onReady?.(handle); + return () => editor.dispose(); + }, []); + + return
; + }, +); + +export default MonacoSQLEditor; +``` + +### activate.ts + +```typescript +import { editors } from '@apache-superset/core'; +import MonacoSQLEditor from './MonacoSQLEditor'; + +export function activate(context) { + // Register the Monaco editor for SQL + const disposable = editors.registerEditorProvider( + { + id: 'monaco-sql-editor.sql', + name: 'Monaco SQL Editor', + languages: ['sql'], + }, + MonacoSQLEditor, + ); + + context.subscriptions.push(disposable); +} +``` + +## Handling Hotkeys + +Superset passes keyboard shortcuts via the `hotkeys` prop. Each hotkey includes an `exec` function that receives the `EditorHandle`: + +```typescript +interface EditorHotkey { + name: string; + key: string; // e.g., "Ctrl-Enter", "Alt-Shift-F" + description?: string; + exec: (handle: EditorHandle) => void; +} +``` + +Your editor must register these hotkeys with your editor framework and call `exec(handle)` when triggered. + +## Keywords + +Superset passes static autocomplete suggestions via the `keywords` prop. These include table names, column names, and SQL functions based on the current database context: + +```typescript +interface EditorKeyword { + name: string; + value?: string; // Text to insert (defaults to name) + meta?: string; // Category like "table", "column", "function" + score?: number; // Sorting priority +} +``` + +Your editor should convert these to your framework's completion format and register them for autocomplete. + +## Completion Providers + +For dynamic autocomplete (e.g., fetching suggestions as the user types), implement and register a `CompletionProvider` via the `EditorHandle`: + +```typescript +const provider: CompletionProvider = { + id: 'my-sql-completions', + triggerCharacters: ['.', ' '], + provideCompletions: async (content, position, context) => { + // Use context.metadata for database info + // Return array of CompletionItem + return [ + { label: 'SELECT', insertText: 'SELECT', kind: 'keyword' }, + // ... + ]; + }, +}; + +// Register during editor initialization +const disposable = handle.registerCompletionProvider(provider); +``` + +## Next Steps + +- **[SQL Lab Extension Points](./sqllab)** - Learn about other SQL Lab customizations +- **[Contribution Types](../contribution-types)** - Explore other contribution types +- **[Development](../development)** - Set up your development environment diff --git a/docs/developer_portal/extensions/registry.md b/docs/developer_portal/extensions/registry.md index 35fa409a7275..20ebfaf15963 100644 --- a/docs/developer_portal/extensions/registry.md +++ b/docs/developer_portal/extensions/registry.md @@ -38,6 +38,7 @@ This page serves as a registry of community-created Superset extensions. These e | [SQL Lab Result Stats](https://github.com/michael-s-molina/superset-extensions/tree/main/result_stats) | A SQL Lab extension that automatically computes statistics for query results, providing type-aware analysis including numeric metrics (min, max, mean, median, std dev), string analysis (length, empty counts), and date range information. | Michael S. Molina | Result Stats | | [SQL Snippets](https://github.com/michael-s-molina/superset-extensions/tree/main/sql_snippets) | A SQL Lab extension that provides reusable SQL code snippets, enabling quick insertion of commonly used code blocks such as license headers, author information, and frequently used SQL patterns. | Michael S. Molina | SQL Snippets | | [SQL Lab Query Estimator](https://github.com/michael-s-molina/superset-extensions/tree/main/query_estimator) | A SQL Lab panel that analyzes query execution plans to estimate resource impact, detect performance issues like Cartesian products and high-cost operations, and visualize the query plan tree. | Michael S. Molina | Query Estimator | +| [Editors Bundle](https://github.com/michael-s-molina/superset-extensions/tree/main/editors_bundle) | A Superset extension that demonstrates how to provide custom code editors for different languages. This extension showcases the editor contribution system by registering alternative editors that can replace Superset's default Ace editor. | Michael S. Molina | Editors Bundle | ## How to Add Your Extension diff --git a/docs/static/img/extensions/editors-bundle.png b/docs/static/img/extensions/editors-bundle.png new file mode 100644 index 000000000000..229a9b0c21ec Binary files /dev/null and b/docs/static/img/extensions/editors-bundle.png differ diff --git a/scripts/check-type.js b/scripts/check-type.js index 76fb7c23888b..609b0cb391d6 100755 --- a/scripts/check-type.js +++ b/scripts/check-type.js @@ -27,6 +27,11 @@ const { existsSync } = require("node:fs"); const { chdir, cwd } = require("node:process"); const { createRequire } = require("node:module"); +// Increase memory limit for TypeScript compiler +if (!process.env.NODE_OPTIONS?.includes("--max-old-space-size")) { + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS || ""} --max-old-space-size=8192`.trim(); +} + const SUPERSET_ROOT = dirname(__dirname); const PACKAGE_ARG_REGEX = /^package=/; const EXCLUDE_DECLARATION_DIR_REGEX = /^excludeDeclarationDir=/; diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts index 56724e6e9edf..8ca480aab6d1 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts @@ -61,8 +61,8 @@ describe('SqlLab query tabs', () => { }); it('opens a new tab by a button and a shortcut', () => { - const editorContent = '#ace-editor .ace_content'; - const editorInput = '#ace-editor textarea'; + const editorContent = '.ace_editor .ace_content'; + const editorInput = '.ace_editor textarea'; const queryLimitSelector = '#js-sql-toolbar .limitDropdown'; cy.get(tabSelector).then(tabs => { const initialTabCount = tabs.length; diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 8d9518381ccc..c7e2a5f04a35 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -84,7 +84,7 @@ "tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch", "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent", "test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%", - "type": "tsc --noEmit", + "type": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit", "update-maps": "cd plugins/legacy-plugin-chart-country-map/scripts && jupyter nbconvert --to notebook --execute --inplace --allow-errors --ExecutePreprocessor.timeout=1200 'Country Map GeoJSON Generator.ipynb'", "validate-release": "../RELEASING/validate_this_release.sh" }, diff --git a/superset-frontend/packages/superset-core/src/api/contributions.ts b/superset-frontend/packages/superset-core/src/api/contributions.ts index 2cc3bbb6ae2a..174e02848fdd 100644 --- a/superset-frontend/packages/superset-core/src/api/contributions.ts +++ b/superset-frontend/packages/superset-core/src/api/contributions.ts @@ -74,7 +74,27 @@ export interface ViewContribution { } /** - * Aggregates all contributions (commands, menus, and views) provided by an extension or module. + * Describes an editor that can be contributed to the application. + * Extensions declare editor contributions in their extension.json manifest. + */ +export interface EditorContribution { + /** Unique identifier for the editor (e.g., "acme.monaco-sql") */ + id: string; + /** Display name of the editor */ + name: string; + /** Languages this editor supports */ + languages: EditorLanguage[]; + /** Optional description of the editor */ + description?: string; +} + +/** + * Supported editor languages. + */ +export type EditorLanguage = 'sql' | 'json' | 'yaml' | 'markdown' | 'css'; + +/** + * Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module. */ export interface Contributions { /** List of command contributions. */ @@ -87,4 +107,6 @@ export interface Contributions { views: { [key: string]: ViewContribution[]; }; + /** List of editor contributions. */ + editors?: EditorContribution[]; } diff --git a/superset-frontend/packages/superset-core/src/api/editors.ts b/superset-frontend/packages/superset-core/src/api/editors.ts new file mode 100644 index 000000000000..8551ceb6f0ba --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/editors.ts @@ -0,0 +1,381 @@ +/** + * 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 Editors API for Superset extension editor contributions. + * + * This module defines the interfaces and types for editor contributions to the + * Superset platform. Extensions can register custom text editor implementations + * (e.g., Monaco, CodeMirror) through the extension manifest, replacing the + * default Ace editor for specific languages. + */ + +import { ForwardRefExoticComponent, RefAttributes } from 'react'; +import { EditorContribution, EditorLanguage } from './contributions'; +import { Disposable, Event } from './core'; +import type { SupersetTheme } from '../ui'; + +// Re-export contribution types for convenience +export type { EditorContribution, EditorLanguage }; + +/** + * Represents a position in the editor (line and column). + */ +export interface Position { + /** Zero-based line number */ + line: number; + /** Zero-based column number */ + column: number; +} + +/** + * Represents a range in the editor with start and end positions. + */ +export interface Range { + /** Start position of the range */ + start: Position; + /** End position of the range */ + end: Position; +} + +/** + * Represents a selection in the editor. + */ +export interface Selection extends Range { + /** Direction of the selection */ + direction?: 'ltr' | 'rtl'; +} + +/** + * Annotation severity levels for editor markers. + */ +export type AnnotationSeverity = 'error' | 'warning' | 'info'; + +/** + * Represents an annotation (marker/diagnostic) in the editor. + */ +export interface EditorAnnotation { + /** Zero-based line number */ + line: number; + /** Zero-based column number (optional) */ + column?: number; + /** Annotation message to display */ + message: string; + /** Severity level of the annotation */ + severity: AnnotationSeverity; + /** Optional source of the annotation (e.g., "linter", "typescript") */ + source?: string; +} + +/** + * Represents a keyboard shortcut binding. + */ +export interface EditorHotkey { + /** Unique name for the hotkey command */ + name: string; + /** Key binding string (e.g., "Ctrl+Enter", "Alt+Enter") */ + key: string; + /** Description of what the hotkey does */ + description?: string; + /** Function to execute when the hotkey is triggered */ + exec: (handle: EditorHandle) => void; +} + +/** + * Completion item kinds for autocompletion. + */ +export type CompletionItemKind = + | 'text' + | 'method' + | 'function' + | 'constructor' + | 'field' + | 'variable' + | 'class' + | 'interface' + | 'module' + | 'property' + | 'unit' + | 'value' + | 'enum' + | 'keyword' + | 'snippet' + | 'color' + | 'file' + | 'reference' + | 'folder' + | 'constant' + | 'struct' + | 'event' + | 'operator' + | 'typeParameter' + | 'table' + | 'column' + | 'schema' + | 'catalog' + | 'database'; + +/** + * Represents a completion item for autocompletion. + */ +export interface CompletionItem { + /** Display label for the completion item */ + label: string; + /** Text to insert when the item is selected */ + insertText: string; + /** Kind of completion item for icon display */ + kind: CompletionItemKind; + /** Optional documentation to show in the completion popup */ + documentation?: string; + /** Optional detail text to show alongside the label */ + detail?: string; + /** Sorting priority (higher numbers appear first) */ + sortText?: string; + /** Text used for filtering completions */ + filterText?: string; +} + +/** + * Context provided to completion providers. + */ +export interface CompletionContext { + /** Character that triggered the completion (if any) */ + triggerCharacter?: string; + /** How the completion was triggered */ + triggerKind: 'invoke' | 'automatic'; + /** Language of the editor */ + language: EditorLanguage; + /** Generic metadata passed from the host (e.g., SQL Lab can pass database context) */ + metadata?: Record; +} + +/** + * Provider interface for dynamic completions. + */ +export interface CompletionProvider { + /** Unique identifier for this provider */ + id: string; + /** Trigger characters that invoke this provider (e.g., '.', ' ') */ + triggerCharacters?: string[]; + /** + * Provide completions at the given position. + * @param content The editor content + * @param position The cursor position + * @param context Completion context with trigger info and metadata + * @returns Array of completion items or a promise that resolves to them + */ + provideCompletions( + content: string, + position: Position, + context: CompletionContext, + ): CompletionItem[] | Promise; +} + +/** + * A keyword for editor autocomplete. + * This is a generic format that editor implementations convert to their native format. + */ +export interface EditorKeyword { + /** Display name of the keyword */ + name: string; + /** Value to insert when selected (defaults to name if not provided) */ + value?: string; + /** Category/type of the keyword (e.g., "column", "table", "function") */ + meta?: string; + /** Optional score for sorting (higher = more relevant) */ + score?: number; +} + +/** + * Props that all editor implementations must accept. + */ +export interface EditorProps { + /** Instance identifier */ + id: string; + /** Controlled value */ + value: string; + /** Content change handler */ + onChange: (value: string) => void; + /** Blur handler */ + onBlur?: (value: string) => void; + /** Cursor position change handler */ + onCursorPositionChange?: (pos: Position) => void; + /** Selection change handler */ + onSelectionChange?: (sel: Selection[]) => void; + /** Language mode for syntax highlighting */ + language: EditorLanguage; + /** Whether the editor is read-only */ + readOnly?: boolean; + /** Tab size in spaces */ + tabSize?: number; + /** Whether to show line numbers */ + lineNumbers?: boolean; + /** Whether to enable word wrap */ + wordWrap?: boolean; + /** Linting/error annotations */ + annotations?: EditorAnnotation[]; + /** Keyboard shortcuts */ + hotkeys?: EditorHotkey[]; + /** Static keywords for autocomplete */ + keywords?: EditorKeyword[]; + /** CSS height (e.g., "100%", "500px") */ + height?: string; + /** CSS width (e.g., "100%", "800px") */ + width?: string; + /** Callback when editor is ready with imperative handle */ + onReady?: (handle: EditorHandle) => void; + /** Host-specific context (e.g., database info from SQL Lab) */ + metadata?: Record; + /** Theme object for styling the editor */ + theme?: SupersetTheme; +} + +/** + * Imperative API for controlling the editor programmatically. + */ +export interface EditorHandle { + /** Focus the editor */ + focus(): void; + /** Get the current editor content */ + getValue(): string; + /** Set the editor content */ + setValue(value: string): void; + /** Get the current cursor position */ + getCursorPosition(): Position; + /** Move the cursor to a specific position */ + moveCursorToPosition(position: Position): void; + /** Get all selections in the editor */ + getSelections(): Selection[]; + /** Set the selection range */ + setSelection(selection: Range): void; + /** Get the selected text */ + getSelectedText(): string; + /** Insert text at the current cursor position */ + insertText(text: string): void; + /** Execute a named editor command */ + executeCommand(commandName: string): void; + /** Scroll to a specific line */ + scrollToLine(line: number): void; + /** Set annotations (replaces existing) */ + setAnnotations(annotations: EditorAnnotation[]): void; + /** Clear all annotations */ + clearAnnotations(): void; + /** + * Register a completion provider for dynamic suggestions. + * @param provider The completion provider to register + * @returns A Disposable to unregister the provider + */ + registerCompletionProvider(provider: CompletionProvider): Disposable; +} + +/** + * React component type for editor implementations. + * Must be a forwardRef component to expose the EditorHandle. + */ +export type EditorComponent = ForwardRefExoticComponent< + EditorProps & RefAttributes +>; + +/** + * A registered editor provider with its contribution metadata and component. + */ +export interface EditorProvider { + /** The editor contribution metadata */ + contribution: EditorContribution; + /** The React component implementing the editor */ + component: EditorComponent; +} + +/** + * Event fired when an editor provider is registered. + */ +export interface EditorProviderRegisteredEvent { + /** The registered provider */ + provider: EditorProvider; +} + +/** + * Event fired when an editor provider is unregistered. + */ +export interface EditorProviderUnregisteredEvent { + /** The contribution that was unregistered */ + contribution: EditorContribution; +} + +/** + * Register an editor provider for specific languages. + * When an extension registers an editor, it replaces the default for those languages. + * + * @param contribution The editor contribution metadata from extension.json + * @param component The React component implementing EditorProps + * @returns A Disposable to unregister the provider + * + * @example + * ```typescript + * const disposable = registerEditorProvider( + * { + * id: 'acme.monaco-sql', + * name: 'Monaco SQL Editor', + * languages: ['sql'], + * }, + * MonacoSQLEditor + * ); + * context.disposables.push(disposable); + * ``` + */ +export declare function registerEditorProvider( + contribution: EditorContribution, + component: EditorComponent, +): Disposable; + +/** + * Get the editor provider for a specific language. + * Returns the extension's editor if registered, otherwise undefined. + * + * @param language The language to get an editor for + * @returns The editor provider or undefined if no extension provides one + */ +export declare function getEditorProvider( + language: EditorLanguage, +): EditorProvider | undefined; + +/** + * Check if an extension has registered an editor for a language. + * + * @param language The language to check + * @returns True if an extension provides an editor for this language + */ +export declare function hasEditorProvider(language: EditorLanguage): boolean; + +/** + * Get all registered editor providers. + * + * @returns Array of all registered editor providers + */ +export declare function getAllEditorProviders(): EditorProvider[]; + +/** + * Event fired when an editor provider is registered. + */ +export declare const onDidRegisterEditorProvider: Event; + +/** + * Event fired when an editor provider is unregistered. + */ +export declare const onDidUnregisterEditorProvider: Event; diff --git a/superset-frontend/packages/superset-core/src/api/index.ts b/superset-frontend/packages/superset-core/src/api/index.ts index a1964a1eb878..d0df8513d164 100644 --- a/superset-frontend/packages/superset-core/src/api/index.ts +++ b/superset-frontend/packages/superset-core/src/api/index.ts @@ -28,6 +28,7 @@ * - `commands`: Execute Superset commands and operations * - `contributions`: Register UI contributions and customizations * - `core`: Access fundamental Superset types and utilities + * - `editors`: Register custom text editor implementations * - `extensions`: Manage extension lifecycle and metadata * - `sqlLab`: Integrate with SQL Lab functionality */ @@ -36,5 +37,6 @@ export * as authentication from './authentication'; export * as commands from './commands'; export * as contributions from './contributions'; export * as core from './core'; +export * as editors from './editors'; export * as extensions from './extensions'; export * as sqlLab from './sqlLab'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index eecfdf90f882..30bad84f4d8f 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -46,6 +46,7 @@ export { ConfigEditor, type AsyncAceEditorProps, type Editor, + type AceCompleterKeyword, } from './AsyncAceEditor'; export { AutoComplete, type AutoCompleteProps } from './AutoComplete'; export { diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.jsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.jsx index 739204c134ae..e70862f1d9c8 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.jsx +++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.jsx @@ -145,6 +145,26 @@ class ScatterPlotGlowOverlay extends PureComponent { const maxLabel = Math.max(...clusterLabelMap.filter(v => !Number.isNaN(v))); + // Calculate min/max radius values for Pixels mode scaling + let minRadiusValue = Infinity; + let maxRadiusValue = -Infinity; + if (pointRadiusUnit === 'Pixels') { + locations.forEach(location => { + // Accept both null and undefined as "no value" and coerce potential numeric strings + if ( + !location.properties.cluster && + location.properties.radius != null + ) { + const radiusValueRaw = location.properties.radius; + const radiusValue = Number(radiusValueRaw); + if (Number.isFinite(radiusValue)) { + minRadiusValue = Math.min(minRadiusValue, radiusValue); + maxRadiusValue = Math.max(maxRadiusValue, radiusValue); + } + } + }); + } + ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = compositeOperation; @@ -232,6 +252,50 @@ class ScatterPlotGlowOverlay extends PureComponent { pointLatitude, zoom, ); + } else if (pointRadiusUnit === 'Pixels') { + // Scale pixel values to a reasonable range (radius/6 to radius/3) + // This ensures points are visible and proportional to their values + const MIN_POINT_RADIUS = radius / 6; + const MAX_POINT_RADIUS = radius / 3; + + if ( + Number.isFinite(minRadiusValue) && + Number.isFinite(maxRadiusValue) && + maxRadiusValue > minRadiusValue + ) { + // Normalize the value to 0-1 range, then scale to pixel range + const numericPointRadius = Number(pointRadius); + if (!Number.isFinite(numericPointRadius)) { + // fallback to minimum visible size when the value is not a finite number + pointRadius = MIN_POINT_RADIUS; + } else { + const normalizedValueRaw = + (numericPointRadius - minRadiusValue) / + (maxRadiusValue - minRadiusValue); + const normalizedValue = Math.max( + 0, + Math.min(1, normalizedValueRaw), + ); + pointRadius = + MIN_POINT_RADIUS + + normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS); + } + pointLabel = `${roundDecimal(radiusProperty, 2)}`; + } else if ( + Number.isFinite(minRadiusValue) && + minRadiusValue === maxRadiusValue + ) { + // All values are the same, use a fixed medium size + pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2; + pointLabel = `${roundDecimal(radiusProperty, 2)}`; + } else { + // Use raw pixel values if they're already in a reasonable range + pointRadius = Math.max( + MIN_POINT_RADIUS, + Math.min(pointRadius, MAX_POINT_RADIUS), + ); + pointLabel = `${roundDecimal(radiusProperty, 2)}`; + } } } diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.js b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.js index de2da2a73571..14a5581926b0 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.js +++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.js @@ -90,7 +90,9 @@ export default function transformProps(chartProps) { setControlValue('viewport_latitude', latitude); setControlValue('viewport_zoom', zoom); }, - pointRadius: pointRadius === 'Auto' ? DEFAULT_POINT_RADIUS : pointRadius, + // Always use DEFAULT_POINT_RADIUS as the base radius for cluster sizing + // Individual point radii come from geoJSON properties.radius + pointRadius: DEFAULT_POINT_RADIUS, pointRadiusUnit, renderWhileDragging, rgb, diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx new file mode 100644 index 000000000000..27dffc2ed2fd --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx @@ -0,0 +1,346 @@ +/* + * 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 { render } from '@testing-library/react'; +import ScatterPlotGlowOverlay from '../src/ScatterPlotGlowOverlay'; + +// Mock react-map-gl's CanvasOverlay +jest.mock('react-map-gl', () => ({ + CanvasOverlay: ({ redraw }: { redraw: Function }) => { + // Store the redraw function so tests can call it + (global as any).mockRedraw = redraw; + return
; + }, +})); + +// Mock utility functions +jest.mock('../src/utils/luminanceFromRGB', () => ({ + __esModule: true, + default: jest.fn(() => 150), // Return a value above the dark threshold +})); + +// Test helpers +const createMockCanvas = () => { + const ctx: any = { + clearRect: jest.fn(), + beginPath: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + fillText: jest.fn(), + measureText: jest.fn(() => ({ width: 10 })), + createRadialGradient: jest.fn(() => ({ + addColorStop: jest.fn(), + })), + globalCompositeOperation: '', + fillStyle: '', + font: '', + textAlign: '', + textBaseline: '', + shadowBlur: 0, + shadowColor: '', + }; + + return ctx; +}; + +const createMockRedrawParams = (overrides = {}) => ({ + width: 800, + height: 600, + ctx: createMockCanvas(), + isDragging: false, + project: (lngLat: [number, number]) => lngLat, + ...overrides, +}); + +const createLocation = ( + coordinates: [number, number], + properties: Record, +) => ({ + geometry: { coordinates }, + properties, +}); + +const defaultProps = { + lngLatAccessor: (loc: any) => loc.geometry.coordinates, + dotRadius: 60, + rgb: ['', 255, 0, 0] as any, + globalOpacity: 1, +}; + +test('renders map with varying radius values in Pixels mode', () => { + const locations = [ + createLocation([100, 100], { radius: 10, cluster: false }), + createLocation([200, 200], { radius: 50, cluster: false }), + createLocation([300, 300], { radius: 100, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('handles dataset with uniform radius values', () => { + const locations = [ + createLocation([100, 100], { radius: 50, cluster: false }), + createLocation([200, 200], { radius: 50, cluster: false }), + createLocation([300, 300], { radius: 50, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders successfully when data contains non-finite values', () => { + const locations = [ + createLocation([100, 100], { radius: 10, cluster: false }), + createLocation([200, 200], { radius: NaN, cluster: false }), + createLocation([300, 300], { radius: 100, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('handles radius values provided as strings', () => { + const locations = [ + createLocation([100, 100], { radius: '10', cluster: false }), + createLocation([200, 200], { radius: '50', cluster: false }), + createLocation([300, 300], { radius: '100', cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders points when radius values are missing', () => { + const locations = [ + createLocation([100, 100], { radius: null, cluster: false }), + createLocation([200, 200], { radius: undefined, cluster: false }), + createLocation([300, 300], { cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders both cluster and non-cluster points correctly', () => { + const locations = [ + createLocation([100, 100], { radius: 10, cluster: false }), + createLocation([200, 200], { + radius: 999, + cluster: true, + point_count: 5, + sum: 100, + }), + createLocation([300, 300], { radius: 100, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders map with multiple points with different radius values', () => { + const locations = [ + createLocation([100, 100], { radius: 10, cluster: false }), + createLocation([200, 200], { radius: 42.567, cluster: false }), + createLocation([300, 300], { radius: 100, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders map with Kilometers mode', () => { + const locations = [ + createLocation([100, 50], { radius: 10, cluster: false }), + createLocation([200, 50], { radius: 5, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders map with Miles mode', () => { + const locations = [ + createLocation([100, 50], { radius: 5, cluster: false }), + createLocation([200, 50], { radius: 10, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('displays metric property labels on points', () => { + const locations = [ + createLocation([100, 100], { radius: 50, metric: 123.456, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('handles empty dataset without errors', () => { + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('handles extreme outlier radius values without breaking', () => { + const locations = [ + createLocation([100, 100], { radius: 1, cluster: false }), + createLocation([200, 200], { radius: 50, cluster: false }), + createLocation([300, 300], { radius: 999999, cluster: false }), + ]; + + expect(() => { + render( + , + ); + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); + +test('renders successfully with mixed extreme and negative radius values', () => { + const locations = [ + createLocation([100, 100], { radius: 0.001, cluster: false }), + createLocation([150, 150], { radius: 5, cluster: false }), + createLocation([200, 200], { radius: 100, cluster: false }), + createLocation([250, 250], { radius: 50000, cluster: false }), + createLocation([300, 300], { radius: -10, cluster: false }), + ]; + + expect(() => { + render( + , + ); + }).not.toThrow(); + + expect(() => { + const redrawParams = createMockRedrawParams(); + (global as any).mockRedraw(redrawParams); + }).not.toThrow(); +}); diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx deleted file mode 100644 index 0a9231e26a98..000000000000 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx +++ /dev/null @@ -1,249 +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 { useState, useEffect, useRef } from 'react'; -import type { IAceEditor } from 'react-ace/lib/types'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { usePrevious } from '@superset-ui/core'; -import { css, useTheme } from '@apache-superset/core/ui'; -import { Global } from '@emotion/react'; - -import { SQL_EDITOR_LEFTBAR_WIDTH } from 'src/SqlLab/constants'; -import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab'; -import { FullSQLEditor as AceEditor } from '@superset-ui/core/components'; -import type { KeyboardShortcut } from 'src/SqlLab/components/KeyboardShortcutButton'; -import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; -import { SqlLabRootState, type CursorPosition } from 'src/SqlLab/types'; -import { useAnnotations } from './useAnnotations'; -import { useKeywords } from './useKeywords'; - -type HotKey = { - key: KeyboardShortcut; - descr?: string; - name: string; - func: (aceEditor: IAceEditor) => void; -}; - -type AceEditorWrapperProps = { - autocomplete: boolean; - onBlur: (sql: string) => void; - onChange: (sql: string) => void; - queryEditorId: string; - onCursorPositionChange: (position: CursorPosition) => void; - height: string; - hotkeys: HotKey[]; -}; - -const AceEditorWrapper = ({ - autocomplete, - onBlur = () => {}, - onChange = () => {}, - queryEditorId, - onCursorPositionChange, - height, - hotkeys, -}: AceEditorWrapperProps) => { - const dispatch = useDispatch(); - const queryEditor = useQueryEditor(queryEditorId, [ - 'id', - 'dbId', - 'sql', - 'catalog', - 'schema', - 'templateParams', - 'tabViewId', - ]); - // Prevent a maximum update depth exceeded error - // by skipping access the unsaved query editor state - const cursorPosition = useSelector( - ({ sqlLab: { queryEditors } }) => { - const { cursorPosition } = { - ...queryEditors.find(({ id }) => id === queryEditorId), - }; - return cursorPosition ?? { row: 0, column: 0 }; - }, - shallowEqual, - ); - - const currentSql = queryEditor.sql ?? ''; - const [sql, setSql] = useState(currentSql); - const theme = useTheme(); - - // The editor changeSelection is called multiple times in a row, - // faster than React reconciliation process, so the selected text - // needs to be stored out of the state to ensure changes to it - // get saved immediately - const currentSelectionCache = useRef(''); - - useEffect(() => { - // Making sure no text is selected from previous mount - dispatch(queryEditorSetSelectedText(queryEditor, null)); - }, []); - - const prevSql = usePrevious(currentSql); - - useEffect(() => { - if (currentSql !== prevSql) { - setSql(currentSql); - } - }, [currentSql]); - - const onBlurSql = () => { - onBlur(sql); - }; - - const onAltEnter = () => { - onBlur(sql); - }; - - const onEditorLoad = (editor: any) => { - editor.commands.addCommand({ - name: 'runQuery', - bindKey: { win: 'Alt-enter', mac: 'Alt-enter' }, - exec: () => { - onAltEnter(); - }, - }); - - hotkeys.forEach(keyConfig => { - editor.commands.addCommand({ - name: keyConfig.name, - bindKey: { win: keyConfig.key, mac: keyConfig.key }, - exec: keyConfig.func, - }); - }); - const marginSize = theme.sizeUnit * 2; - editor.renderer.setScrollMargin(marginSize, marginSize, 0, 0); - editor.$blockScrolling = Infinity; // eslint-disable-line no-param-reassign - editor.selection.on('changeSelection', () => { - const selectedText = editor.getSelectedText(); - - // Backspace trigger 1 character selection, ignoring - if ( - selectedText !== currentSelectionCache.current && - selectedText.length !== 1 - ) { - dispatch(queryEditorSetSelectedText(queryEditor, selectedText)); - } - - currentSelectionCache.current = selectedText; - }); - - editor.selection.on('changeCursor', () => { - const cursor = editor.getCursorPosition(); - onCursorPositionChange(cursor); - }); - - const { row, column } = cursorPosition; - editor.moveCursorToPosition({ row, column }); - editor.focus(); - editor.scrollToLine(row, true, true); - }; - - const onChangeText = (text: string) => { - if (text !== sql) { - setSql(text); - onChange(text); - } - }; - - const { data: annotations } = useAnnotations({ - dbId: queryEditor.dbId, - catalog: queryEditor.catalog, - schema: queryEditor.schema, - sql: currentSql, - templateParams: queryEditor.templateParams, - }); - - const keywords = useKeywords( - { - queryEditorId, - dbId: queryEditor.dbId, - catalog: queryEditor.catalog, - schema: queryEditor.schema, - tabViewId: queryEditor.tabViewId, - }, - !autocomplete, - ); - - return ( - <> - - - - ); -}; - -export default AceEditorWrapper; diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx b/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx similarity index 74% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx rename to superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx index 5b38f0478397..3252f716579d 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx +++ b/superset-frontend/src/SqlLab/components/EditorWrapper/EditorWrapper.test.tsx @@ -21,11 +21,8 @@ import { render, waitFor, createStore } from 'spec/helpers/testing-library'; import { QueryEditor } from 'src/SqlLab/types'; import { Store } from 'redux'; import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures'; -import AceEditorWrapper from 'src/SqlLab/components/AceEditorWrapper'; -import { - FullSQLEditor, - type AsyncAceEditorProps, -} from '@superset-ui/core/components'; +import EditorWrapper from 'src/SqlLab/components/EditorWrapper'; +import type { editors } from '@apache-superset/core'; import { queryEditorSetCursorPosition, queryEditorSetDb, @@ -43,17 +40,21 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
)); -jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({ - FullSQLEditor: jest - .fn() - .mockImplementation((props: AsyncAceEditorProps) => ( -
{JSON.stringify(props)}
- )), +// Mock EditorHost from the editors module +const MockEditorHost = jest + .fn() + .mockImplementation((props: editors.EditorProps) => ( +
{JSON.stringify(props)}
+ )); + +jest.mock('src/core/editors', () => ({ + ...jest.requireActual('src/core/editors'), + EditorHost: (props: editors.EditorProps) => MockEditorHost(props), })); const setup = (queryEditor: QueryEditor, store?: Store) => render( - ); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('AceEditorWrapper', () => { +describe('EditorWrapper', () => { beforeEach(() => { - (FullSQLEditor as any as jest.Mock).mockClear(); + MockEditorHost.mockClear(); }); - test('renders ace editor including sql value', async () => { + test('renders editor including sql value', async () => { const store = createStore(initialState, reducerIndex); const { getByTestId } = setup(defaultQueryEditor, store); - await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument()); + await waitFor(() => expect(getByTestId('editor-host')).toBeInTheDocument()); - expect(getByTestId('react-ace')).toHaveTextContent( + expect(getByTestId('editor-host')).toHaveTextContent( JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1), ); }); @@ -101,10 +102,10 @@ describe('AceEditorWrapper', () => { ); const { getByTestId } = setup(defaultQueryEditor, store); - expect(getByTestId('react-ace')).not.toHaveTextContent( + expect(getByTestId('editor-host')).not.toHaveTextContent( JSON.stringify({ value: expectedSql }).slice(1, -1), ); - expect(getByTestId('react-ace')).toHaveTextContent( + expect(getByTestId('editor-host')).toHaveTextContent( JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1), ); }); @@ -113,14 +114,14 @@ describe('AceEditorWrapper', () => { const store = createStore(initialState, reducerIndex); setup(defaultQueryEditor, store); - expect(FullSQLEditor).toHaveBeenCalled(); - const renderCount = (FullSQLEditor as any as jest.Mock).mock.calls.length; + expect(MockEditorHost).toHaveBeenCalled(); + const renderCount = MockEditorHost.mock.calls.length; const updatedCursorPosition = { row: 1, column: 9 }; store.dispatch( queryEditorSetCursorPosition(defaultQueryEditor, updatedCursorPosition), ); - expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount); + expect(MockEditorHost).toHaveBeenCalledTimes(renderCount); store.dispatch(queryEditorSetDb(defaultQueryEditor, 2)); - expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount + 1); + expect(MockEditorHost).toHaveBeenCalledTimes(renderCount + 1); }); }); diff --git a/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx new file mode 100644 index 000000000000..e6b5ac1afea7 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/EditorWrapper/index.tsx @@ -0,0 +1,363 @@ +/** + * 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 { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { usePrevious } from '@superset-ui/core'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { Global } from '@emotion/react'; +import type { editors } from '@apache-superset/core'; + +import { SQL_EDITOR_LEFTBAR_WIDTH } from 'src/SqlLab/constants'; +import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab'; +import type { KeyboardShortcut } from 'src/SqlLab/components/KeyboardShortcutButton'; +import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; +import { SqlLabRootState, type CursorPosition } from 'src/SqlLab/types'; +import { EditorHost } from 'src/core/editors'; +import { useAnnotations } from './useAnnotations'; +import { useKeywords } from './useKeywords'; + +type EditorHandle = editors.EditorHandle; +type EditorHotkey = editors.EditorHotkey; +type EditorAnnotation = editors.EditorAnnotation; + +type HotKey = { + key: KeyboardShortcut; + descr?: string; + name: string; + func: (editor: EditorHandle) => void; +}; + +type EditorWrapperProps = { + autocomplete: boolean; + onBlur: (sql: string) => void; + onChange: (sql: string) => void; + queryEditorId: string; + onCursorPositionChange: (position: CursorPosition) => void; + height: string; + hotkeys: HotKey[]; +}; + +/** + * Convert legacy HotKey format to EditorHotkey format. + */ +const convertHotkeys = ( + hotkeys: HotKey[], + onRunQuery: () => void, +): EditorHotkey[] => { + const result: EditorHotkey[] = [ + // Add the built-in run query hotkey + { + name: 'runQuery', + key: 'Alt-enter', + description: 'Run query', + exec: () => onRunQuery(), + }, + ]; + + hotkeys.forEach(keyConfig => { + result.push({ + name: keyConfig.name, + key: keyConfig.key, + description: keyConfig.descr, + exec: keyConfig.func, + }); + }); + + return result; +}; + +/** + * Ace annotation format returned from useAnnotations when data is available. + */ +type AceAnnotation = { + row: number; + column: number; + text: string | null; + type: string; +}; + +/** + * Type guard to check if an annotation is in Ace format. + */ +const isAceAnnotation = (ann: unknown): ann is AceAnnotation => + typeof ann === 'object' && + ann !== null && + 'row' in ann && + 'column' in ann && + 'text' in ann && + 'type' in ann; + +/** + * Convert annotation array to EditorAnnotation format. + * Handles the union type returned from useAnnotations. + */ +const convertAnnotations = ( + annotations?: unknown[], +): EditorAnnotation[] | undefined => { + if (!annotations || annotations.length === 0) return undefined; + // Check if first item is in Ace format (has row, column, text, type) + if (!isAceAnnotation(annotations[0])) return undefined; + return (annotations as AceAnnotation[]).map(ann => ({ + line: ann.row, + column: ann.column, + message: ann.text ?? '', + severity: ann.type as EditorAnnotation['severity'], + })); +}; + +/** + * EditorWrapper component that renders the SQL editor using EditorHost. + * Uses the default Ace editor or an extension-provided editor based on + * what's registered with the editors API. + */ +const EditorWrapper = ({ + autocomplete, + onBlur = () => {}, + onChange = () => {}, + queryEditorId, + onCursorPositionChange, + height, + hotkeys, +}: EditorWrapperProps) => { + const dispatch = useDispatch(); + const queryEditor = useQueryEditor(queryEditorId, [ + 'id', + 'dbId', + 'sql', + 'catalog', + 'schema', + 'templateParams', + 'tabViewId', + ]); + // Prevent a maximum update depth exceeded error + // by skipping access the unsaved query editor state + const cursorPosition = useSelector( + ({ sqlLab: { queryEditors } }) => { + const editor = queryEditors.find(({ id }) => id === queryEditorId); + return editor?.cursorPosition ?? { row: 0, column: 0 }; + }, + shallowEqual, + ); + + const currentSql = queryEditor.sql ?? ''; + const [sql, setSql] = useState(currentSql); + const theme = useTheme(); + const editorHandleRef = useRef(null); + + // The editor changeSelection is called multiple times in a row, + // faster than React reconciliation process, so the selected text + // needs to be stored out of the state to ensure changes to it + // get saved immediately + const currentSelectionCache = useRef(''); + + useEffect(() => { + // Making sure no text is selected from previous mount + dispatch(queryEditorSetSelectedText(queryEditor, null)); + }, []); + + const prevSql = usePrevious(currentSql); + + useEffect(() => { + if (currentSql !== prevSql) { + setSql(currentSql); + } + }, [currentSql]); + + const onBlurSql = useCallback( + (value: string) => { + onBlur(value); + }, + [onBlur], + ); + + const onAltEnter = useCallback(() => { + onBlur(sql); + }, [onBlur, sql]); + + const onChangeText = useCallback( + (text: string) => { + if (text !== sql) { + setSql(text); + onChange(text); + } + }, + [sql, onChange], + ); + + // Handle cursor position changes + const handleCursorPositionChange = useCallback( + (pos: { line: number; column: number }) => { + onCursorPositionChange({ row: pos.line, column: pos.column }); + }, + [onCursorPositionChange], + ); + + // Handle selection changes + const handleSelectionChange = useCallback( + ( + selections: Array<{ + start: { line: number; column: number }; + end: { line: number; column: number }; + }>, + ) => { + if (editorHandleRef.current && selections.length > 0) { + const selectedText = editorHandleRef.current.getSelectedText(); + if ( + selectedText !== currentSelectionCache.current && + selectedText.length !== 1 + ) { + dispatch(queryEditorSetSelectedText(queryEditor, selectedText)); + } + currentSelectionCache.current = selectedText; + } + }, + [dispatch, queryEditor], + ); + + // Handle editor ready callback + const handleEditorReady = useCallback( + (handle: EditorHandle) => { + editorHandleRef.current = handle; + // Set initial cursor position + const { row, column } = cursorPosition; + handle.moveCursorToPosition({ line: row, column }); + handle.focus(); + handle.scrollToLine(row); + }, + [cursorPosition], + ); + + const { data: annotations } = useAnnotations({ + dbId: queryEditor.dbId, + catalog: queryEditor.catalog, + schema: queryEditor.schema, + sql: currentSql, + templateParams: queryEditor.templateParams, + }); + + const keywords = useKeywords( + { + queryEditorId, + dbId: queryEditor.dbId, + catalog: queryEditor.catalog, + schema: queryEditor.schema, + tabViewId: queryEditor.tabViewId, + }, + !autocomplete, + ); + + // Convert hotkeys and annotations for the editor + const editorHotkeys = useMemo( + () => convertHotkeys(hotkeys, onAltEnter), + [hotkeys, onAltEnter], + ); + const editorAnnotations = useMemo( + () => convertAnnotations(annotations), + [annotations], + ); + + // Metadata for the editor (e.g., database context for completions) + const metadata = useMemo( + () => ({ + dbId: queryEditor.dbId, + catalog: queryEditor.catalog, + schema: queryEditor.schema, + queryEditorId, + }), + [queryEditor.dbId, queryEditor.catalog, queryEditor.schema, queryEditorId], + ); + + // Global styles for the editor + const globalStyles = ( + + ); + + return ( + <> + {globalStyles} + + + ); +}; + +// Export with the legacy name for backward compatibility +export default EditorWrapper; diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.test.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts similarity index 100% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.test.ts rename to superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.ts similarity index 100% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper/useAnnotations.ts rename to superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.ts diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts similarity index 100% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts rename to superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.ts similarity index 100% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts rename to superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.ts diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx index b15bbdadc466..6eab25215f3b 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx @@ -206,7 +206,7 @@ describe('SqlEditor', () => { }); // Update other similar tests with timeouts - test('render an AceEditorWrapper', async () => { + test('render an EditorWrapper', async () => { const { findByTestId, unmount } = setup(mockedProps, store); await waitFor( @@ -217,7 +217,7 @@ describe('SqlEditor', () => { unmount(); }, 15000); - test('skip rendering an AceEditorWrapper when the current tab is inactive', async () => { + test('skip rendering an EditorWrapper when the current tab is inactive', async () => { const { queryByTestId } = setup( { ...mockedProps, diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index da2caaf006bc..18262329970a 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -28,7 +28,7 @@ import { FC, } from 'react'; -import type AceEditor from 'react-ace'; +import type { editors } from '@apache-superset/core'; import useEffectEvent from 'src/hooks/useEffectEvent'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -111,7 +111,7 @@ import SaveQuery, { QueryPayload } from '../SaveQuery'; import ScheduleQueryButton from '../ScheduleQueryButton'; import EstimateQueryCostButton from '../EstimateQueryCostButton'; import ShareSqlLabQuery from '../ShareSqlLabQuery'; -import AceEditorWrapper from '../AceEditorWrapper'; +import EditorWrapper from '../EditorWrapper'; import RunQueryActionButton from '../RunQueryActionButton'; import QueryLimitSelect from '../QueryLimitSelect'; import KeyboardShortcutButton, { @@ -429,73 +429,136 @@ const SqlEditor: FC = ({ }, [dispatch, queryEditor.sql, startQuery, stopQuery, formatCurrentQuery]); const hotkeys = useMemo(() => { - // Get all hotkeys including ace editor hotkeys + // Get all hotkeys including editor hotkeys // Get the user's OS const userOS = detectOS(); + + type EditorHandle = editors.EditorHandle; + + /** + * Find the position of a semicolon in the given direction from a starting position. + * Returns the position after the semicolon (for backwards) or at the semicolon (for forwards). + */ + const findSemicolon = ( + lines: string[], + fromLine: number, + fromColumn: number, + backwards: boolean, + ): { line: number; column: number } | null => { + if (backwards) { + // Search backwards: start from current position going up + for (let line = fromLine; line >= 0; line -= 1) { + const lineText = lines[line]; + const searchEnd = line === fromLine ? fromColumn : lineText.length; + // Search from right to left within the line + const idx = lineText.lastIndexOf(';', searchEnd - 1); + if (idx !== -1) { + // Return position after the semicolon + return { line, column: idx + 1 }; + } + } + } else { + // Search forwards: start from current position going down + for (let line = fromLine; line < lines.length; line += 1) { + const lineText = lines[line]; + const searchStart = line === fromLine ? fromColumn + 1 : 0; + const idx = lineText.indexOf(';', searchStart); + if (idx !== -1) { + // Return position at the semicolon (end of statement) + return { line, column: idx + 1 }; + } + } + } + return null; + }; + const base = [ ...getHotkeyConfig(), { name: 'runQuery3', key: KeyboardShortcut.CtrlShiftEnter, descr: KEY_MAP[KeyboardShortcut.CtrlShiftEnter], - func: (editor: AceEditor['editor']) => { - if (!editor.getValue().trim()) { + func: (editor: EditorHandle) => { + const value = editor.getValue(); + if (!value.trim()) { return; } - const session = editor.getSession(); + + const lines = value.split('\n'); const cursorPosition = editor.getCursorPosition(); - const totalLine = session.getLength(); - const currentRow = editor.getFirstVisibleRow(); - const semicolonEnd = editor.find(';', { - backwards: false, - skipCurrent: true, - }); - let end; - if (semicolonEnd) { - ({ end } = semicolonEnd); - } - if (!end || end.row < cursorPosition.row) { + const totalLines = lines.length; + + // Find the end of the statement (next semicolon or end of file) + const semicolonEnd = findSemicolon( + lines, + cursorPosition.line, + cursorPosition.column, + false, + ); + let end: { line: number; column: number }; + if (semicolonEnd && semicolonEnd.line >= cursorPosition.line) { + end = semicolonEnd; + } else { + // No semicolon found forward, use end of file + const lastLineIndex = totalLines - 1; end = { - row: totalLine + 1, - column: 0, + line: lastLineIndex, + column: lines[lastLineIndex]?.length ?? 0, }; } - const semicolonStart = editor.find(';', { - backwards: true, - skipCurrent: true, - }); - let start; + + // Find the start of the statement (previous semicolon or start of file) + const semicolonStart = findSemicolon( + lines, + cursorPosition.line, + cursorPosition.column, + true, + ); + let start: { line: number; column: number } | undefined; if (semicolonStart) { - start = semicolonStart.end; + start = semicolonStart; } - let currentLine = start?.row; + + // Determine the starting line + let currentLine = start?.line; if ( - !currentLine || - currentLine > cursorPosition.row || - (currentLine === cursorPosition.row && + currentLine === undefined || + currentLine > cursorPosition.line || + (currentLine === cursorPosition.line && (start?.column || 0) > cursorPosition.column) ) { currentLine = 0; } + + // Skip empty lines to find actual content let content = - currentLine === start?.row - ? session.getLine(currentLine).slice(start.column).trim() - : session.getLine(currentLine).trim(); - while (!content && currentLine < totalLine) { + currentLine === start?.line && start + ? lines[currentLine].slice(start.column).trim() + : (lines[currentLine]?.trim() ?? ''); + while (!content && currentLine < totalLines - 1) { currentLine += 1; - content = session.getLine(currentLine).trim(); + content = lines[currentLine]?.trim() ?? ''; } - if (currentLine !== start?.row) { - start = { row: currentLine, column: 0 }; + + // Adjust start if we skipped lines + if (start === undefined || currentLine !== start.line) { + start = { line: currentLine, column: 0 }; } - editor.selection.setSelectionRange({ - start: start ?? { row: 0, column: 0 }, + + // Set selection and run query + editor.setSelection({ + start: start ?? { line: 0, column: 0 }, end, }); startQuery(); - editor.selection.clearSelection(); + + // Clear selection and restore cursor position + editor.setSelection({ + start: cursorPosition, + end: cursorPosition, + }); editor.moveCursorToPosition(cursorPosition); - editor.scrollToRow(currentRow); + editor.scrollToLine(cursorPosition.line); }, }, ]; @@ -504,8 +567,14 @@ const SqlEditor: FC = ({ name: 'previousLine', key: KeyboardShortcut.CtrlP, descr: KEY_MAP[KeyboardShortcut.CtrlP], - func: editor => { - editor.navigateUp(); + func: (editor: EditorHandle) => { + const pos = editor.getCursorPosition(); + if (pos.line > 0) { + editor.moveCursorToPosition({ + line: pos.line - 1, + column: pos.column, + }); + } }, }); } @@ -903,7 +972,7 @@ const SqlEditor: FC = ({ {({ height }) => isActive && ( - () => ( jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
)); -jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({ - ConfigEditor: ({ value }: { value: string }) => ( +jest.mock('src/core/editors', () => ({ + EditorHost: ({ value }: { value: string }) => (
{value}
), })); diff --git a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx index 402097d0a778..9b27e45427b7 100644 --- a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx @@ -22,15 +22,15 @@ import { styled } from '@apache-superset/core/ui'; import { debounce } from 'lodash'; import { Badge, - ConfigEditor, InfoTooltip, ModalTrigger, Tooltip, Constants, } from '@superset-ui/core/components'; +import { EditorHost } from 'src/core/editors'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; -const StyledConfigEditor = styled(ConfigEditor)` +const StyledEditorHost = styled(EditorHost)` &.ace_editor { border: 1px solid ${({ theme }) => theme.colorBorder}; } @@ -87,14 +87,12 @@ const TemplateParamsEditor = ({ {' '} {t('syntax.')} -
diff --git a/superset-frontend/src/components/SQLEditorWithValidation/index.tsx b/superset-frontend/src/components/SQLEditorWithValidation/index.tsx index 61e020d1bc68..de483fd90c3a 100644 --- a/superset-frontend/src/components/SQLEditorWithValidation/index.tsx +++ b/superset-frontend/src/components/SQLEditorWithValidation/index.tsx @@ -18,34 +18,39 @@ */ import { useCallback, useState, useEffect, forwardRef } from 'react'; import { t } from '@apache-superset/core'; +import type { editors } from '@apache-superset/core'; import { SupersetClient } from '@superset-ui/core'; import { styled } from '@apache-superset/core/ui'; -import { - SQLEditor, - Button, - Icons, - Tooltip, - Flex, -} from '@superset-ui/core/components'; +import { Button, Icons, Tooltip, Flex } from '@superset-ui/core/components'; +import { EditorHost } from 'src/core/editors'; import { ExpressionType, ValidationError, ValidationResponse, } from '../../types/SqlExpression'; +type EditorKeyword = editors.EditorKeyword; + interface SQLEditorWithValidationProps { - // SQLEditor props - we'll accept any props that SQLEditor accepts + // Editor props value: string; onChange: (value: string) => void; // Validation-specific props showValidation?: boolean; expressionType?: ExpressionType; - datasourceId?: number; + datasourceId?: number | string; datasourceType?: string; clause?: string; // For filters: "WHERE" or "HAVING" onValidationComplete?: (isValid: boolean, errors?: ValidationError[]) => void; - // Any other props will be passed through to SQLEditor - [key: string]: any; + // Editor appearance props + height?: string; + width?: string; + /** Whether to show line numbers */ + lineNumbers?: boolean; + /** Whether to enable word wrap */ + wordWrap?: boolean; + /** Keywords for autocomplete */ + keywords?: EditorKeyword[]; } const StyledValidationMessage = styled.div<{ @@ -71,7 +76,10 @@ const StyledValidationMessage = styled.div<{ } `; -const SQLEditorWithValidation = forwardRef( +const SQLEditorWithValidation = forwardRef< + editors.EditorHandle, + SQLEditorWithValidationProps +>( ( { // Required props @@ -84,8 +92,12 @@ const SQLEditorWithValidation = forwardRef( datasourceType, clause, onValidationComplete, - // All other props will be passed through to SQLEditor - ...sqlEditorProps + // Editor appearance props + height, + width, + lineNumbers, + wordWrap, + keywords, }, ref, ) => { @@ -187,11 +199,17 @@ const SQLEditorWithValidation = forwardRef( return ( - {showValidation && ( diff --git a/superset-frontend/src/core/editors/AceEditorProvider.tsx b/superset-frontend/src/core/editors/AceEditorProvider.tsx new file mode 100644 index 000000000000..018be92f2df9 --- /dev/null +++ b/superset-frontend/src/core/editors/AceEditorProvider.tsx @@ -0,0 +1,325 @@ +/** + * 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 Default Ace Editor provider implementation. + * + * This module wraps the existing Ace editor components to implement the + * standard EditorProps interface, serving as the default editor when no + * extension provides a custom implementation. + */ + +import { + useRef, + useEffect, + useCallback, + useImperativeHandle, + forwardRef, + type Ref, +} from 'react'; +import type AceEditor from 'react-ace'; +import type { editors } from '@apache-superset/core'; +import { + FullSQLEditor, + JsonEditor, + MarkdownEditor, + CssEditor, + ConfigEditor, + type AceCompleterKeyword, +} from '@superset-ui/core/components'; +import { Disposable } from '../models'; + +type EditorKeyword = editors.EditorKeyword; +type EditorProps = editors.EditorProps; +type EditorHandle = editors.EditorHandle; +type Position = editors.Position; +type Range = editors.Range; +type Selection = editors.Selection; +type EditorAnnotation = editors.EditorAnnotation; +type CompletionProvider = editors.CompletionProvider; + +/** + * Maps EditorLanguage to the corresponding Ace editor component. + */ +const getEditorComponent = (language: string) => { + switch (language) { + case 'sql': + return FullSQLEditor; + case 'json': + return JsonEditor; + case 'markdown': + return MarkdownEditor; + case 'css': + return CssEditor; + case 'yaml': + return ConfigEditor; + default: + console.warn( + `Unknown editor language "${language}", falling back to SQL editor`, + ); + return FullSQLEditor; + } +}; + +/** + * Converts EditorAnnotation to Ace annotation format. + */ +const toAceAnnotation = (annotation: EditorAnnotation) => ({ + row: annotation.line, + column: annotation.column ?? 0, + text: annotation.message, + type: annotation.severity, +}); + +/** + * Creates an EditorHandle implementation backed by an Ace editor instance. + */ +const createAceEditorHandle = ( + aceEditorRef: React.RefObject, + completionProviders: React.MutableRefObject>, +): EditorHandle => ({ + focus: () => { + aceEditorRef.current?.editor?.focus(); + }, + + getValue: () => aceEditorRef.current?.editor?.getValue() ?? '', + + setValue: (value: string) => { + aceEditorRef.current?.editor?.setValue(value, -1); + }, + + getCursorPosition: (): Position => { + const pos = aceEditorRef.current?.editor?.getCursorPosition(); + return { + line: pos?.row ?? 0, + column: pos?.column ?? 0, + }; + }, + + moveCursorToPosition: (position: Position) => { + aceEditorRef.current?.editor?.moveCursorToPosition({ + row: position.line, + column: position.column, + }); + }, + + getSelections: (): Selection[] => { + const selection = aceEditorRef.current?.editor?.getSelection(); + if (!selection) return []; + const range = selection.getRange(); + return [ + { + start: { line: range.start.row, column: range.start.column }, + end: { line: range.end.row, column: range.end.column }, + }, + ]; + }, + + setSelection: (range: Range) => { + const editor = aceEditorRef.current?.editor; + if (editor) { + editor.selection.setSelectionRange({ + start: { row: range.start.line, column: range.start.column }, + end: { row: range.end.line, column: range.end.column }, + }); + } + }, + + getSelectedText: () => aceEditorRef.current?.editor?.getSelectedText() ?? '', + + insertText: (text: string) => { + aceEditorRef.current?.editor?.insert(text); + }, + + executeCommand: (commandName: string) => { + const editor = aceEditorRef.current?.editor; + if (editor?.commands) { + editor.commands.exec(commandName, editor, {}); + } + }, + + scrollToLine: (line: number) => { + aceEditorRef.current?.editor?.scrollToLine(line, true, true); + }, + + setAnnotations: (annotations: EditorAnnotation[]) => { + const session = aceEditorRef.current?.editor?.getSession(); + if (session) { + session.setAnnotations(annotations.map(toAceAnnotation)); + } + }, + + clearAnnotations: () => { + const session = aceEditorRef.current?.editor?.getSession(); + if (session) { + session.clearAnnotations(); + } + }, + + registerCompletionProvider: (provider: CompletionProvider): Disposable => { + completionProviders.current.set(provider.id, provider); + return new Disposable(() => { + completionProviders.current.delete(provider.id); + }); + }, +}); + +/** + * Converts generic EditorKeyword to Ace's AceCompleterKeyword format. + */ +const toAceKeyword = (keyword: EditorKeyword): AceCompleterKeyword => ({ + name: keyword.name, + value: keyword.value ?? keyword.name, + score: keyword.score ?? 0, + meta: keyword.meta ?? '', +}); + +/** + * Default Ace-based editor provider component. + * Implements the standard EditorProps interface while wrapping the existing + * Ace editor components. + */ +const AceEditorProvider = forwardRef( + (props, ref) => { + const { + id, + value, + onChange, + onBlur, + onCursorPositionChange, + onSelectionChange, + language, + readOnly, + tabSize, + lineNumbers, + wordWrap, + annotations, + hotkeys, + keywords, + height = '100%', + width = '100%', + onReady, + } = props; + + const aceEditorRef = useRef(null); + const completionProviders = useRef>( + new Map(), + ); + + // Create the handle + const handle = createAceEditorHandle(aceEditorRef, completionProviders); + + // Expose handle via ref + useImperativeHandle(ref, () => handle, []); + + // Notify when ready + useEffect(() => { + if (onReady && aceEditorRef.current?.editor) { + onReady(handle); + } + }, [onReady, handle]); + + // Handle editor load + const onEditorLoad = useCallback( + (editor: AceEditor['editor']) => { + // Register hotkeys + if (hotkeys) { + hotkeys.forEach(hotkey => { + editor.commands.addCommand({ + name: hotkey.name, + bindKey: { win: hotkey.key, mac: hotkey.key }, + exec: () => hotkey.exec(handle), + }); + }); + } + + // Set up cursor position change listener + if (onCursorPositionChange) { + editor.selection.on('changeCursor', () => { + const cursor = editor.getCursorPosition(); + onCursorPositionChange({ + line: cursor.row, + column: cursor.column, + }); + }); + } + + // Set up selection change listener + if (onSelectionChange) { + editor.selection.on('changeSelection', () => { + const range = editor.getSelection().getRange(); + onSelectionChange([ + { + start: { line: range.start.row, column: range.start.column }, + end: { line: range.end.row, column: range.end.column }, + }, + ]); + }); + } + + // Focus the editor + editor.focus(); + }, + [hotkeys, onCursorPositionChange, onSelectionChange, handle], + ); + + // Handle blur + const handleBlur = useCallback(() => { + if (onBlur) { + onBlur(value); + } + }, [onBlur, value]); + + // Get the appropriate editor component + const EditorComponent = getEditorComponent(language); + + // Convert annotations to Ace format + const aceAnnotations = annotations?.map(toAceAnnotation); + + // Convert generic keywords to Ace format + const aceKeywords = keywords?.map(toAceKeyword); + + return ( + } + name={id} + mode={language} + value={value} + onChange={onChange} + onBlur={handleBlur} + onLoad={onEditorLoad} + height={height} + width={width} + readOnly={readOnly} + tabSize={tabSize} + showGutter={lineNumbers !== false} + wrapEnabled={wordWrap} + annotations={aceAnnotations} + keywords={aceKeywords} + enableLiveAutocompletion + editorProps={{ $blockScrolling: true }} + showLoadingForImport + /> + ); + }, +); + +AceEditorProvider.displayName = 'AceEditorProvider'; + +export default AceEditorProvider; diff --git a/superset-frontend/src/core/editors/EditorHost.test.tsx b/superset-frontend/src/core/editors/EditorHost.test.tsx new file mode 100644 index 000000000000..195f5e44d454 --- /dev/null +++ b/superset-frontend/src/core/editors/EditorHost.test.tsx @@ -0,0 +1,90 @@ +/** + * 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 { render, screen, cleanup } from 'spec/helpers/testing-library'; +import type { editors } from '@apache-superset/core'; + +afterEach(() => { + cleanup(); +}); + +type EditorProps = editors.EditorProps; + +// Mock the AceEditorProvider to avoid loading the full Ace editor in tests +jest.mock('./AceEditorProvider', () => ({ + __esModule: true, + default: ({ id, value, language }: EditorProps) => ( +
+ {id} + {value} + {language} +
+ ), +})); + +// Mock the EditorProviders - return undefined (no extension provider) +jest.mock('./EditorProviders', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + getProvider: jest.fn().mockReturnValue(undefined), + hasProvider: jest.fn().mockReturnValue(false), + onDidRegister: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidUnregister: jest.fn().mockReturnValue({ dispose: jest.fn() }), + }), + }, +})); + +// Import EditorHost after mocks are set up +import EditorHost from './EditorHost'; + +const defaultProps: EditorProps = { + id: 'test-editor', + value: 'SELECT * FROM table', + onChange: jest.fn(), + language: 'sql', +}; + +test('renders default Ace editor when no extension provider is registered', () => { + render(); + + expect(screen.getByTestId('ace-editor-provider')).toBeInTheDocument(); + expect(screen.getByTestId('ace-editor-id')).toHaveTextContent('test-editor'); + expect(screen.getByTestId('ace-editor-value')).toHaveTextContent( + 'SELECT * FROM table', + ); + expect(screen.getByTestId('ace-editor-language')).toHaveTextContent('sql'); +}); + +test('passes id prop to the editor', () => { + render(); + + expect(screen.getByTestId('ace-editor-id')).toHaveTextContent('custom-id'); +}); + +test('passes value prop to the editor', () => { + render(); + + expect(screen.getByTestId('ace-editor-value')).toHaveTextContent('SELECT 1'); +}); + +test('passes language option to the editor', () => { + render(); + + expect(screen.getByTestId('ace-editor-language')).toHaveTextContent('json'); +}); diff --git a/superset-frontend/src/core/editors/EditorHost.tsx b/superset-frontend/src/core/editors/EditorHost.tsx new file mode 100644 index 000000000000..a6caa4743b19 --- /dev/null +++ b/superset-frontend/src/core/editors/EditorHost.tsx @@ -0,0 +1,128 @@ +/** + * 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 EditorHost component for dynamic editor resolution. + * + * This component resolves and renders the appropriate editor implementation + * based on the language and any registered extension providers. If an extension + * has registered an editor for the language, it uses that; otherwise, it falls + * back to the default Ace editor. + */ + +import { useState, useEffect, forwardRef } from 'react'; +import type { editors, contributions } from '@apache-superset/core'; +import { useTheme } from '@apache-superset/core/ui'; +import EditorProviders from './EditorProviders'; +import AceEditorProvider from './AceEditorProvider'; + +type EditorLanguage = contributions.EditorLanguage; +type EditorProps = editors.EditorProps; +type EditorHandle = editors.EditorHandle; + +/** + * Props for EditorHost component. + * Uses the generic EditorProps interface that all editor implementations support. + */ +export type EditorHostProps = EditorProps; + +/** + * Hook to track editor provider changes. + * Returns the provider for the specified language and re-renders when it changes. + */ +const useEditorProvider = (language: EditorLanguage) => { + const manager = EditorProviders.getInstance(); + const [provider, setProvider] = useState(() => manager.getProvider(language)); + + useEffect(() => { + // Helper to safely update provider state, always fetching latest from manager + const updateProvider = () => { + setProvider(prev => { + const current = manager.getProvider(language); + return current !== prev ? current : prev; + }); + }; + + // Subscribe to provider changes + const registerDisposable = manager.onDidRegister(event => { + if (event.provider.contribution.languages.includes(language)) { + updateProvider(); + } + }); + + const unregisterDisposable = manager.onDidUnregister(event => { + if (event.contribution.languages.includes(language)) { + updateProvider(); + } + }); + + // Check for provider on mount (in case it was registered before this component mounted) + updateProvider(); + + return () => { + registerDisposable.dispose(); + unregisterDisposable.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [language, manager]); + + return provider; +}; + +/** + * EditorHost component that dynamically resolves and renders the appropriate editor. + * + * This component serves as the main entry point for rendering editors in Superset. + * It checks if an extension has registered a custom editor for the requested language + * and uses that if available; otherwise, it falls back to the default Ace editor. + * + * @example + * ```tsx + * + * ``` + */ +const EditorHost = forwardRef((props, ref) => { + const { language } = props; + const theme = useTheme(); + const provider = useEditorProvider(language); + + // Merge theme into props + const propsWithTheme = { ...props, theme }; + + // Use extension-provided editor if available + if (provider) { + const EditorComponent = provider.component; + return ; + } + + // Fall back to default Ace editor + return ; +}); + +EditorHost.displayName = 'EditorHost'; + +export default EditorHost; + +export { EditorHost }; diff --git a/superset-frontend/src/core/editors/EditorProviders.test.ts b/superset-frontend/src/core/editors/EditorProviders.test.ts new file mode 100644 index 000000000000..74abb6088cfd --- /dev/null +++ b/superset-frontend/src/core/editors/EditorProviders.test.ts @@ -0,0 +1,339 @@ +/** + * 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 { editors, contributions } from '@apache-superset/core'; +import EditorProviders from './EditorProviders'; + +type EditorLanguage = contributions.EditorLanguage; +type EditorContribution = editors.EditorContribution; +type EditorComponent = editors.EditorComponent; + +/** + * Creates a mock editor contribution for testing. + */ +function createMockEditorContribution( + overrides: Partial = {}, +): EditorContribution { + return { + id: 'test.mock-editor', + name: 'Mock Editor', + languages: ['sql'] as EditorLanguage[], + description: 'A mock editor for testing', + ...overrides, + }; +} + +/** + * Creates a mock editor component for testing. + */ +function createMockEditorComponent(): EditorComponent { + return jest.fn(() => null) as unknown as EditorComponent; +} + +beforeEach(() => { + // Reset the singleton instance before each test + const manager = EditorProviders.getInstance(); + manager.reset(); +}); + +test('creates singleton instance', () => { + const manager1 = EditorProviders.getInstance(); + const manager2 = EditorProviders.getInstance(); + + expect(manager1).toBe(manager2); + expect(manager1).toBeInstanceOf(EditorProviders); +}); + +test('registers and retrieves a provider', () => { + const manager = EditorProviders.getInstance(); + const contribution = createMockEditorContribution(); + const component = createMockEditorComponent(); + + manager.registerProvider(contribution, component); + + const provider = manager.getProvider('sql'); + expect(provider).toBeDefined(); + expect(provider?.contribution).toEqual(contribution); + expect(provider?.component).toBe(component); +}); + +test('hasProvider returns true when provider is registered', () => { + const manager = EditorProviders.getInstance(); + const contribution = createMockEditorContribution(); + const component = createMockEditorComponent(); + + expect(manager.hasProvider('sql')).toBe(false); + + manager.registerProvider(contribution, component); + + expect(manager.hasProvider('sql')).toBe(true); +}); + +test('hasProvider returns false for unregistered languages', () => { + const manager = EditorProviders.getInstance(); + const contribution = createMockEditorContribution({ + languages: ['sql'], + }); + const component = createMockEditorComponent(); + + manager.registerProvider(contribution, component); + + expect(manager.hasProvider('sql')).toBe(true); + expect(manager.hasProvider('json')).toBe(false); + expect(manager.hasProvider('markdown')).toBe(false); +}); + +test('returns undefined for unregistered language', () => { + const manager = EditorProviders.getInstance(); + + const provider = manager.getProvider('sql'); + expect(provider).toBeUndefined(); +}); + +test('getAllProviders returns all registered providers', () => { + const manager = EditorProviders.getInstance(); + + expect(manager.getAllProviders()).toHaveLength(0); + + const contribution1 = createMockEditorContribution({ + id: 'editor-1', + languages: ['sql'], + }); + const contribution2 = createMockEditorContribution({ + id: 'editor-2', + languages: ['json'], + }); + + manager.registerProvider(contribution1, createMockEditorComponent()); + manager.registerProvider(contribution2, createMockEditorComponent()); + + const providers = manager.getAllProviders(); + expect(providers).toHaveLength(2); +}); + +test('unregisters provider when disposable is disposed', () => { + const manager = EditorProviders.getInstance(); + const contribution = createMockEditorContribution(); + const component = createMockEditorComponent(); + + const disposable = manager.registerProvider(contribution, component); + + expect(manager.hasProvider('sql')).toBe(true); + + disposable.dispose(); + + expect(manager.hasProvider('sql')).toBe(false); + expect(manager.getProvider('sql')).toBeUndefined(); +}); + +test('supports multiple languages per provider', () => { + const manager = EditorProviders.getInstance(); + const contribution = createMockEditorContribution({ + languages: ['sql', 'json', 'yaml'], + }); + const component = createMockEditorComponent(); + + manager.registerProvider(contribution, component); + + expect(manager.hasProvider('sql')).toBe(true); + expect(manager.hasProvider('json')).toBe(true); + expect(manager.hasProvider('yaml')).toBe(true); + expect(manager.hasProvider('markdown')).toBe(false); + + // All should return the same provider + const sqlProvider = manager.getProvider('sql'); + const jsonProvider = manager.getProvider('json'); + const yamlProvider = manager.getProvider('yaml'); + + expect(sqlProvider).toBe(jsonProvider); + expect(jsonProvider).toBe(yamlProvider); +}); + +test('warns when registering duplicate provider id', () => { + const manager = EditorProviders.getInstance(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const contribution = createMockEditorContribution({ + id: 'duplicate-editor', + }); + + manager.registerProvider(contribution, createMockEditorComponent()); + + // Try to register with same ID + const disposable = manager.registerProvider( + { ...contribution }, + createMockEditorComponent(), + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Editor provider with id "duplicate-editor" is already registered.', + ); + + // Disposing the duplicate should be a no-op + disposable.dispose(); + + // Original provider should still be registered + expect(manager.hasProvider('sql')).toBe(true); + + consoleWarnSpy.mockRestore(); +}); + +test('fires onDidRegister event when provider is registered', () => { + const manager = EditorProviders.getInstance(); + const listener = jest.fn(); + + manager.onDidRegister(listener); + + const contribution = createMockEditorContribution(); + const component = createMockEditorComponent(); + + manager.registerProvider(contribution, component); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + provider: { + contribution, + component, + }, + }); +}); + +test('fires onDidUnregister event when provider is unregistered', () => { + const manager = EditorProviders.getInstance(); + const listener = jest.fn(); + + manager.onDidUnregister(listener); + + const contribution = createMockEditorContribution(); + const disposable = manager.registerProvider( + contribution, + createMockEditorComponent(), + ); + + disposable.dispose(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + contribution, + }); +}); + +test('event listeners can be disposed', () => { + const manager = EditorProviders.getInstance(); + const listener = jest.fn(); + + const listenerDisposable = manager.onDidRegister(listener); + + // Register first provider - listener should be called + manager.registerProvider( + createMockEditorContribution({ id: 'editor-1' }), + createMockEditorComponent(), + ); + + expect(listener).toHaveBeenCalledTimes(1); + + // Dispose the listener + listenerDisposable.dispose(); + + // Register second provider - listener should not be called + manager.registerProvider( + createMockEditorContribution({ id: 'editor-2', languages: ['json'] }), + createMockEditorComponent(), + ); + + 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( + createMockEditorContribution(), + 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(); + + manager.registerProvider( + createMockEditorContribution({ id: 'editor-1', languages: ['sql'] }), + createMockEditorComponent(), + ); + manager.registerProvider( + createMockEditorContribution({ id: 'editor-2', languages: ['json'] }), + createMockEditorComponent(), + ); + + expect(manager.getAllProviders()).toHaveLength(2); + expect(manager.hasProvider('sql')).toBe(true); + expect(manager.hasProvider('json')).toBe(true); + + manager.reset(); + + expect(manager.getAllProviders()).toHaveLength(0); + expect(manager.hasProvider('sql')).toBe(false); + expect(manager.hasProvider('json')).toBe(false); +}); + +test('later registration replaces language mapping', () => { + const manager = EditorProviders.getInstance(); + + const contribution1 = createMockEditorContribution({ + id: 'editor-1', + name: 'Editor 1', + languages: ['sql'], + }); + const contribution2 = createMockEditorContribution({ + id: 'editor-2', + name: 'Editor 2', + languages: ['sql'], + }); + + manager.registerProvider(contribution1, createMockEditorComponent()); + manager.registerProvider(contribution2, createMockEditorComponent()); + + // The second registration should replace the first for the 'sql' language + const provider = manager.getProvider('sql'); + expect(provider?.contribution.id).toBe('editor-2'); + expect(provider?.contribution.name).toBe('Editor 2'); + + // But both providers should exist + expect(manager.getAllProviders()).toHaveLength(2); +}); diff --git a/superset-frontend/src/core/editors/EditorProviders.ts b/superset-frontend/src/core/editors/EditorProviders.ts new file mode 100644 index 000000000000..e9390fd99c1d --- /dev/null +++ b/superset-frontend/src/core/editors/EditorProviders.ts @@ -0,0 +1,243 @@ +/** + * 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 { editors, contributions } from '@apache-superset/core'; +import { Disposable } from '../models'; + +type EditorLanguage = contributions.EditorLanguage; +type EditorProvider = editors.EditorProvider; +type EditorContribution = editors.EditorContribution; +type EditorComponent = editors.EditorComponent; +type EditorProviderRegisteredEvent = editors.EditorProviderRegisteredEvent; +type EditorProviderUnregisteredEvent = editors.EditorProviderUnregisteredEvent; + +/** + * Listener function type for events. + */ +type Listener = (e: T) => void; + +/** + * Simple event emitter for editor provider lifecycle events. + */ +class EventEmitter { + private listeners: Set> = new Set(); + + /** + * Subscribe to this event. + * @param listener The listener function to call when the event is fired. + * @returns A Disposable to unsubscribe from the event. + */ + subscribe(listener: Listener): Disposable { + this.listeners.add(listener); + return new Disposable(() => { + this.listeners.delete(listener); + }); + } + + /** + * Fire the event with the given data. + * @param data The event data to pass to listeners. + */ + fire(data: T): void { + this.listeners.forEach(listener => { + try { + listener(data); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error in event listener:', error); + } + }); + } +} + +/** + * Singleton manager for editor providers. + * Handles registration, resolution, and lifecycle of custom editor implementations. + */ +class EditorProviders { + private static instance: EditorProviders; + + /** + * Map of provider ID to EditorProvider. + */ + private providers: Map = new Map(); + + /** + * Map of language to provider ID for quick lookups. + */ + private languageToProvider: Map = new Map(); + + /** + * Event emitter for provider registration events. + */ + private registerEmitter = new EventEmitter(); + + /** + * Event emitter for provider unregistration events. + */ + private unregisterEmitter = + new EventEmitter(); + + // eslint-disable-next-line no-useless-constructor + private constructor() { + // Private constructor for singleton pattern + } + + /** + * Get the singleton instance of EditorProviders. + * @returns The singleton instance. + */ + public static getInstance(): EditorProviders { + if (!EditorProviders.instance) { + EditorProviders.instance = new EditorProviders(); + } + return EditorProviders.instance; + } + + /** + * Register an editor provider. + * When registered, the provider replaces the default editor for its supported languages. + * + * @param contribution The editor contribution metadata. + * @param component The React component implementing the editor. + * @returns A Disposable to unregister the provider. + */ + public registerProvider( + contribution: EditorContribution, + component: EditorComponent, + ): Disposable { + const { id, languages } = contribution; + + // Check if provider with this ID already exists + if (this.providers.has(id)) { + // eslint-disable-next-line no-console + console.warn(`Editor provider with id "${id}" is already registered.`); + return new Disposable(() => {}); + } + + const provider: EditorProvider = { + contribution, + component, + }; + + // Register the provider + this.providers.set(id, provider); + + // Map languages to this provider + languages.forEach(language => { + this.languageToProvider.set(language, id); + }); + + // Fire registration event + this.registerEmitter.fire({ provider }); + + // Return disposable for cleanup + return new Disposable(() => { + this.unregisterProvider(id); + }); + } + + /** + * Unregister an editor provider by ID. + * @param id The provider ID to unregister. + */ + private unregisterProvider(id: string): void { + const provider = this.providers.get(id); + if (!provider) { + return; + } + + const { contribution } = provider; + + // Remove language mappings for this provider + contribution.languages.forEach(language => { + if (this.languageToProvider.get(language) === id) { + this.languageToProvider.delete(language); + } + }); + + // Remove the provider + this.providers.delete(id); + + // Fire unregistration event + this.unregisterEmitter.fire({ contribution }); + } + + /** + * Get the editor provider for a specific language. + * @param language The language to get a provider for. + * @returns The provider or undefined if none is registered. + */ + public getProvider(language: EditorLanguage): EditorProvider | undefined { + const providerId = this.languageToProvider.get(language); + if (!providerId) { + return undefined; + } + return this.providers.get(providerId); + } + + /** + * Check if a provider is registered for a language. + * @param language The language to check. + * @returns True if a provider is registered. + */ + public hasProvider(language: EditorLanguage): boolean { + return this.languageToProvider.has(language); + } + + /** + * Get all registered providers. + * @returns Array of all registered providers. + */ + public getAllProviders(): EditorProvider[] { + return Array.from(this.providers.values()); + } + + /** + * Subscribe to provider registration events. + * @param listener The listener function. + * @returns A Disposable to unsubscribe. + */ + public onDidRegister( + listener: Listener, + ): Disposable { + return this.registerEmitter.subscribe(listener); + } + + /** + * Subscribe to provider unregistration events. + * @param listener The listener function. + * @returns A Disposable to unsubscribe. + */ + public onDidUnregister( + listener: Listener, + ): Disposable { + return this.unregisterEmitter.subscribe(listener); + } + + /** + * Reset the manager state (for testing purposes). + */ + public reset(): void { + this.providers.clear(); + this.languageToProvider.clear(); + } +} + +export default EditorProviders; diff --git a/superset-frontend/src/core/editors/index.ts b/superset-frontend/src/core/editors/index.ts new file mode 100644 index 000000000000..26e6e63e811f --- /dev/null +++ b/superset-frontend/src/core/editors/index.ts @@ -0,0 +1,130 @@ +/** + * 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 Implementation of the editors API for Superset. + * + * This module provides the runtime implementation of the editor registration + * and resolution functions declared in the API types. + */ + +import type { contributions } from '@apache-superset/core'; +import { editors as editorsApi } from '@apache-superset/core'; +import { Disposable } from '../models'; +import EditorProviders from './EditorProviders'; + +type EditorLanguage = contributions.EditorLanguage; +type EditorProvider = editorsApi.EditorProvider; +type EditorContribution = editorsApi.EditorContribution; +type EditorComponent = editorsApi.EditorComponent; +type EditorProviderRegisteredEvent = editorsApi.EditorProviderRegisteredEvent; +type EditorProviderUnregisteredEvent = + editorsApi.EditorProviderUnregisteredEvent; + +/** + * Register an editor provider for specific languages. + * When an extension registers an editor, it replaces the default for those languages. + * + * @param contribution The editor contribution metadata from extension.json + * @param component The React component implementing EditorProps + * @returns A Disposable to unregister the provider + */ +export const registerEditorProvider = ( + contribution: EditorContribution, + component: EditorComponent, +): Disposable => { + const manager = EditorProviders.getInstance(); + return manager.registerProvider(contribution, component); +}; + +/** + * Get the editor provider for a specific language. + * Returns the extension's editor if registered, otherwise undefined. + * + * @param language The language to get an editor for + * @returns The editor provider or undefined if no extension provides one + */ +export const getEditorProvider = ( + language: EditorLanguage, +): EditorProvider | undefined => { + const manager = EditorProviders.getInstance(); + return manager.getProvider(language); +}; + +/** + * Check if an extension has registered an editor for a language. + * + * @param language The language to check + * @returns True if an extension provides an editor for this language + */ +export const hasEditorProvider = (language: EditorLanguage): boolean => { + const manager = EditorProviders.getInstance(); + return manager.hasProvider(language); +}; + +/** + * Get all registered editor providers. + * + * @returns Array of all registered editor providers + */ +export const getAllEditorProviders = (): EditorProvider[] => { + const manager = EditorProviders.getInstance(); + return manager.getAllProviders(); +}; + +/** + * Event fired when an editor provider is registered. + * Subscribe to this event to react when extensions register new editors. + */ +export const onDidRegisterEditorProvider = ( + listener: (e: EditorProviderRegisteredEvent) => void, +): Disposable => { + const manager = EditorProviders.getInstance(); + return manager.onDidRegister(listener); +}; + +/** + * Event fired when an editor provider is unregistered. + * Subscribe to this event to react when extensions unregister editors. + */ +export const onDidUnregisterEditorProvider = ( + listener: (e: EditorProviderUnregisteredEvent) => void, +): Disposable => { + const manager = EditorProviders.getInstance(); + return manager.onDidUnregister(listener); +}; + +/** + * Editors API object for use in the extension system. + */ +export const editors: typeof editorsApi = { + registerEditorProvider, + getEditorProvider, + hasEditorProvider, + getAllEditorProviders, + onDidRegisterEditorProvider, + onDidUnregisterEditorProvider, +}; + +export { EditorProviders }; + +// Component exports +export { default as EditorHost } from './EditorHost'; +export type { EditorHostProps } from './EditorHost'; +export { default as AceEditorProvider } from './AceEditorProvider'; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index 3e4d8b06f309..463beffcfda0 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -40,6 +40,7 @@ export const core: typeof coreType = { export * from './authentication'; export * from './commands'; +export * from './editors'; export * from './extensions'; export * from './models'; export * from './sqlLab'; diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index b3a458e76e56..4b4ec463f23f 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -23,7 +23,6 @@ import { Form, Collapse, CollapseLabelInModal, - JsonEditor, } from '@superset-ui/core/components'; import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor'; import { type TagType } from 'src/components'; @@ -450,8 +449,6 @@ const PropertiesModal = ({ ); }); } - - JsonEditor.preload(); }, [ currentDashboardInfo, fetchDashboardDetails, diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/AdvancedSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/AdvancedSection.tsx index da1f1d990fbc..9717a2de9ba6 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/AdvancedSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/AdvancedSection.tsx @@ -18,11 +18,32 @@ */ import { t } from '@apache-superset/core'; import { styled } from '@apache-superset/core/ui'; -import { JsonEditor } from '@superset-ui/core/components'; +import type { editors } from '@apache-superset/core'; +import { EditorHost } from 'src/core/editors'; import { ModalFormField } from 'src/components/Modal'; import { ValidationObject } from 'src/components/Modal/useModalValidation'; -const StyledJsonEditor = styled(JsonEditor)` +type EditorAnnotation = editors.EditorAnnotation; + +/** + * Convert Ace annotation format to EditorAnnotation format. + */ +const toEditorAnnotations = ( + aceAnnotations: Array<{ + type: string; + row: number; + column: number; + text: string; + }>, +): EditorAnnotation[] => + aceAnnotations.map(ann => ({ + severity: ann.type as EditorAnnotation['severity'], + line: ann.row, + column: ann.column, + message: ann.text, + })); + +const StyledEditorHost = styled(EditorHost)` /* Border is already applied by AceEditor itself */ `; @@ -54,17 +75,17 @@ const AdvancedSection = ({ } bottomSpacing={false} > - ); diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx index 3ed4247d6aca..1aa5e4b5412b 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx @@ -24,12 +24,13 @@ import { FeatureFlag, } from '@superset-ui/core'; import { styled, Alert } from '@apache-superset/core/ui'; -import { CssEditor, Select, Switch } from '@superset-ui/core/components'; +import { Select, Switch } from '@superset-ui/core/components'; +import { EditorHost } from 'src/core/editors'; import rison from 'rison'; import ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect'; import { ModalFormField } from 'src/components/Modal'; -const StyledCssEditor = styled(CssEditor)` +const StyledEditorHost = styled(EditorHost)` border-radius: ${({ theme }) => theme.borderRadius}px; border: 1px solid ${({ theme }) => theme.colorBorder}; `; @@ -254,14 +255,14 @@ const StylingSection = ({ )} bottomSpacing={false} > - diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx index 3bded7c80218..6b451a3faafb 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx @@ -22,7 +22,8 @@ import { connect } from 'react-redux'; import cx from 'classnames'; import { t, css, styled } from '@apache-superset/core/ui'; -import { SafeMarkdown, MarkdownEditor } from '@superset-ui/core/components'; +import { SafeMarkdown } from '@superset-ui/core/components'; +import { EditorHost } from 'src/core/editors'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; @@ -192,11 +193,10 @@ class Markdown extends PureComponent { (prevProps.component.meta.width !== this.props.component.meta.width || prevProps.columnWidth !== this.props.columnWidth) ) { - this.state.editor.resize(true); - } - // pre-load AceEditor when entering edit mode - if (this.props.editMode) { - MarkdownEditor.preload(); + // Handle both Ace editor (resize method) and EditorHandle (no resize needed) + if (typeof this.state.editor.resize === 'function') { + this.state.editor.resize(true); + } } } @@ -211,7 +211,12 @@ class Markdown extends PureComponent { } setEditor(editor) { - editor.getSession().setUseWrapMode(true); + // EditorHandle or Ace editor instance + // For Ace: editor.getSession().setUseWrapMode(true) + // For EditorHandle: wrapEnabled is handled via options + if (editor?.getSession) { + editor.getSession().setUseWrapMode(true); + } this.setState({ editor, }); @@ -282,20 +287,27 @@ class Markdown extends PureComponent { renderEditMode() { return ( - delete" to give an empty editor typeof this.state.markdownSource === 'string' ? this.state.markdownSource : MARKDOWN_PLACE_HOLDER } + language="markdown" readOnly={false} - onLoad={this.setEditor} + lineNumbers={false} + wordWrap + onReady={handle => { + // The handle provides access to the underlying editor for resize + if (handle && typeof handle.focus === 'function') { + this.setEditor(handle); + } + }} data-test="editor" /> ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx index 14fc956d88fd..74183fcf2398 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx @@ -30,6 +30,25 @@ import { mockStore } from 'spec/fixtures/mockStore'; import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout'; import MarkdownConnected from './Markdown'; +jest.mock('src/core/editors', () => ({ + EditorHost: ({ + value, + onChange, + onBlur, + }: { + value: string; + onChange?: (v: string) => void; + onBlur?: (v: string) => void; + }) => ( +