This is an automated email from the ASF dual-hosted git repository. villebro pushed a commit to branch villebro/ext-automanifest in repository https://gitbox.apache.org/repos/asf/superset.git
commit 423e5280d08d9a1e5e73ac9e3730c5e25afb6acd Author: Ville Brofeldt <[email protected]> AuthorDate: Mon Feb 9 07:47:35 2026 -0800 feat(extensions): autodetect contributions --- .../extensions/contribution-types.md | 128 ++--- docs/developer_portal/extensions/quick-start.md | 45 +- .../src/superset_core/extensions/types.py | 13 +- superset-core/tests/__init__.py | 16 + superset-core/tests/test_api_decorators.py | 147 ++++++ superset-core/tests/test_mcp_decorators.py | 154 ++++++ .../src/superset_extensions_cli/cli.py | 88 ++-- .../templates/extension.json.j2 | 6 - .../templates/frontend/package.json.j2 | 1 + .../templates/frontend/src/index.tsx.j2 | 39 +- .../templates/frontend/webpack.config.js.j2 | 7 + superset-extensions-cli/tests/test_cli_build.py | 38 +- superset-frontend/package-lock.json | 72 +-- .../packages/superset-core/src/extensions/index.ts | 326 +++++++++++++ .../packages/superset-core/src/index.ts | 1 + .../packages/superset-core/test/extensions.test.ts | 253 ++++++++++ .../packages/webpack-extension-plugin/package.json | 45 ++ .../packages/webpack-extension-plugin/src/index.ts | 532 +++++++++++++++++++++ .../webpack-extension-plugin/test/index.test.ts | 148 ++++++ .../webpack-extension-plugin/tsconfig.json | 19 + .../src/SqlLab/components/SouthPane/index.tsx | 6 +- .../src/extensions/ExtensionLoader.ts | 470 ++++++++++++++++++ .../src/extensions/ExtensionsStartup.tsx | 4 +- superset/static/service-worker.js | 28 +- 24 files changed, 2325 insertions(+), 261 deletions(-) diff --git a/docs/developer_portal/extensions/contribution-types.md b/docs/developer_portal/extensions/contribution-types.md index d88b141e5ff..83b8f4f245d 100644 --- a/docs/developer_portal/extensions/contribution-types.md +++ b/docs/developer_portal/extensions/contribution-types.md @@ -38,9 +38,9 @@ The contribution system provides several key benefits: ## How Contributions Work -Contributions are automatically inferred from source code during build. The build tool scans your code and generates a `manifest.json` with all discovered contributions. +Contributions are automatically discovered from source code at build time. Simply use the `@extension_api`, `@tool`, `@prompt` decorators in Python or `define*()` functions in TypeScript - the build system finds them and generates a `manifest.json` with all discovered contributions. -For advanced use cases, contributions can be manually specified in `extension.json` (overrides auto-discovery). +No manual configuration needed! ## Backend Contributions @@ -91,98 +91,74 @@ See [MCP Integration](./mcp) for more details. ### Views -Add panels or views to the UI: +Add panels or views to the UI using `defineView()`: -```json -"frontend": { - "contributions": { - "views": { - "sqllab": { - "panels": [ - { - "id": "my_extension.main", - "name": "My Panel Name" - } - ] - } - } - } -} +```tsx +import React from 'react'; +import { defineView } from '@apache-superset/core'; +import MyPanel from './MyPanel'; + +export const myView = defineView({ + id: 'main', + title: 'My Panel', + location: 'sqllab.panels', // or dashboard.tabs, explore.panels, etc. + component: () => <MyPanel />, +}); ``` ### Commands -Define executable commands: +Define executable commands using `defineCommand()`: -```json -"frontend": { - "contributions": { - "commands": [ - { - "command": "my_extension.copy_query", - "icon": "CopyOutlined", - "title": "Copy Query", - "description": "Copy the current query" - } - ] - } -} +```tsx +import { defineCommand } from '@apache-superset/core'; + +export const copyQuery = defineCommand({ + id: 'copy_query', + title: 'Copy Query', + icon: 'CopyOutlined', + execute: async () => { + // Copy the current query + navigator.clipboard.writeText(getCurrentQuery()); + }, +}); ``` ### Menus -Add items to menus: +Add items to menus using `defineMenu()`: -```json -"frontend": { - "contributions": { - "menus": { - "sqllab": { - "editor": { - "primary": [ - { - "view": "builtin.editor", - "command": "my_extension.copy_query" - } - ], - "secondary": [ - { - "view": "builtin.editor", - "command": "my_extension.prettify" - } - ], - "context": [ - { - "view": "builtin.editor", - "command": "my_extension.clear" - } - ] - } - } - } - } -} +```tsx +import { defineMenu } from '@apache-superset/core'; + +export const contextMenu = defineMenu({ + id: 'clear_editor', + title: 'Clear Editor', + location: 'sqllab.editor.context', + action: () => { + clearEditor(); + }, +}); ``` ### Editors -Replace the default text editor: +Replace the default text editor using `defineEditor()`: -```json -"frontend": { - "contributions": { - "editors": [ - { - "id": "my_extension.monaco_sql", - "name": "Monaco SQL Editor", - "languages": ["sql"] - } - ] - } -} +```tsx +import React from 'react'; +import { defineEditor } from '@apache-superset/core'; +import MonacoEditor from './MonacoEditor'; + +export const monacoSqlEditor = defineEditor({ + id: 'monaco_sql', + name: 'Monaco SQL Editor', + mimeTypes: ['text/x-sql'], + component: MonacoEditor, +}); ``` -See [Editors Extension Point](./extension-points/editors) for implementation details. +All contributions are automatically discovered at build time and registered at runtime - no manual configuration needed! ## Configuration diff --git a/docs/developer_portal/extensions/quick-start.md b/docs/developer_portal/extensions/quick-start.md index f14e0be23b3..19455b4ba93 100644 --- a/docs/developer_portal/extensions/quick-start.md +++ b/docs/developer_portal/extensions/quick-start.md @@ -417,25 +417,48 @@ Replace the generated code with the extension entry point: ```tsx import React from 'react'; -import { core } from '@apache-superset/core'; +import { defineView } from '@apache-superset/core'; import HelloWorldPanel from './HelloWorldPanel'; -export const activate = (context: core.ExtensionContext) => { - context.disposables.push( - core.registerViewProvider('my-org.hello-world.main', () => <HelloWorldPanel />), - ); -}; +// Define the view - automatically registered when extension loads +export const helloWorldView = defineView({ + id: 'main', + title: 'Hello World', + location: 'sqllab.panels', + component: () => <HelloWorldPanel />, +}); +``` + +That's it! For most extensions, this is all you need. + +**Optional lifecycle callbacks:** -export const deactivate = () => {}; +If you need to run code when your contribution activates or deactivates, add optional callbacks: + +```tsx +export const helloWorldView = defineView({ + id: 'main', + title: 'Hello World', + location: 'sqllab.panels', + component: () => <HelloWorldPanel />, + onActivate: () => { + // Optional: runs when panel is registered + console.log('Hello World panel activated'); + }, + onDeactivate: () => { + // Optional: runs when panel is unregistered + console.log('Hello World panel deactivated'); + }, +}); ``` **Key patterns:** -- `activate` function is called when the extension loads -- `core.registerViewProvider` registers the component with ID `my-org.hello-world.main` (matching `extension.json`) +- `defineView()` automatically handles discovery, registration, and cleanup +- `onActivate` and `onDeactivate` are completely optional - `authentication.getCSRFToken()` retrieves the CSRF token for API calls -- Fetch calls to `/extensions/{publisher}/{name}/{endpoint}` reach your backend API -- `context.disposables.push()` ensures proper cleanup +- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API +- Everything happens automatically - no manual setup required ## Step 6: Install Dependencies diff --git a/superset-core/src/superset_core/extensions/types.py b/superset-core/src/superset_core/extensions/types.py index 2615c65131f..9e345fd696d 100644 --- a/superset-core/src/superset_core/extensions/types.py +++ b/superset-core/src/superset_core/extensions/types.py @@ -222,11 +222,6 @@ class BaseExtension(BaseModel): class ExtensionConfigFrontend(BaseModel): """Frontend section in extension.json.""" - # Optional: if provided, takes precedence over discovery - contributions: FrontendContributions | None = Field( - default=None, - description="Frontend contributions (optional, overrides discovery)", - ) moduleFederation: ModuleFederationConfig = Field( # noqa: N815 default_factory=ModuleFederationConfig, description="Module Federation configuration", @@ -242,12 +237,8 @@ class ExtensionConfigBackend(BaseModel): ) files: list[str] = Field( default_factory=list, - description="Glob patterns for backend Python files", - ) - # Optional: if provided, takes precedence over discovery - contributions: BackendContributions | None = Field( - default=None, - description="Backend contributions (optional, overrides discovery)", + description="Glob patterns for backend Python files (defaults to" + "backend/src/**/*.py)", ) diff --git a/superset-core/tests/__init__.py b/superset-core/tests/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-core/tests/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-core/tests/test_api_decorators.py b/superset-core/tests/test_api_decorators.py new file mode 100644 index 00000000000..c779d3e8eff --- /dev/null +++ b/superset-core/tests/test_api_decorators.py @@ -0,0 +1,147 @@ +# 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. + +"""Tests for REST API decorators in BUILD mode.""" + +from superset_core.api.rest_api import extension_api, RestApi +from superset_core.extensions.context import get_context, RegistrationMode + + +def test_extension_api_decorator_stores_metadata(): + """Test that @extension_api decorator stores metadata.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @extension_api(id="test_api", name="Test API") + class TestAPI(RestApi): + """Test API class.""" + + pass + + # Should have metadata attached + assert hasattr(TestAPI, "__rest_api_metadata__") + meta = TestAPI.__rest_api_metadata__ + assert meta.id == "test_api" + assert meta.name == "Test API" + assert meta.description == "Test API class." + assert meta.base_path == "/test_api" + assert "TestAPI" in meta.module + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_extension_api_decorator_with_custom_metadata(): + """Test @extension_api decorator with custom metadata.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @extension_api( + id="custom_api", + name="Custom API", + description="Custom description", + base_path="/custom/path", + ) + class CustomAPI(RestApi): + """Original docstring.""" + + pass + + meta = CustomAPI.__rest_api_metadata__ + assert meta.id == "custom_api" + assert meta.name == "Custom API" + assert meta.description == "Custom description" + assert meta.base_path == "/custom/path" + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_extension_api_decorator_auto_infers_flask_fields(): + """Test that @extension_api auto-infers Flask-AppBuilder fields.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @extension_api(id="infer_api", name="Infer API") + class InferAPI(RestApi): + """Test auto-inference.""" + + pass + + meta = InferAPI.__rest_api_metadata__ + # Check auto-inferred fields + assert meta.resource_name == "infer_api" + assert meta.openapi_spec_tag == "Infer API" + assert meta.class_permission_name == "infer_api" + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_extension_api_decorator_custom_flask_fields(): + """Test @extension_api with custom Flask-AppBuilder fields.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @extension_api( + id="custom_flask_api", + name="Custom Flask API", + resource_name="custom_resource", + openapi_spec_tag="Custom Tag", + class_permission_name="custom_permission", + ) + class CustomFlaskAPI(RestApi): + """Custom Flask fields.""" + + pass + + meta = CustomFlaskAPI.__rest_api_metadata__ + assert meta.resource_name == "custom_resource" + assert meta.openapi_spec_tag == "Custom Tag" + assert meta.class_permission_name == "custom_permission" + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_extension_api_decorator_does_not_register_in_build_mode(): + """Test that @extension_api doesn't attempt registration in BUILD mode.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + # This should not raise an exception even though no registration mechanism + # is available + @extension_api(id="build_api", name="Build API") + class BuildAPI(RestApi): + """Build mode API.""" + + pass + + # Should have metadata + assert hasattr(BuildAPI, "__rest_api_metadata__") + + finally: + ctx.set_mode(RegistrationMode.HOST) diff --git a/superset-core/tests/test_mcp_decorators.py b/superset-core/tests/test_mcp_decorators.py new file mode 100644 index 00000000000..51e33d44477 --- /dev/null +++ b/superset-core/tests/test_mcp_decorators.py @@ -0,0 +1,154 @@ +# 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. + +"""Tests for MCP decorators in BUILD mode.""" + +from superset_core.extensions.context import get_context, RegistrationMode +from superset_core.mcp import prompt, tool + + +def test_tool_decorator_stores_metadata_in_build_mode(): + """Test that @tool decorator stores metadata when in BUILD mode.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @tool(tags=["database"]) + def test_query_tool(sql: str) -> dict: + """Execute a SQL query.""" + return {"result": "test"} + + # Should have metadata attached + assert hasattr(test_query_tool, "__tool_metadata__") + meta = test_query_tool.__tool_metadata__ + assert meta.id == "test_query_tool" + assert meta.name == "test_query_tool" + assert meta.description == "Execute a SQL query." + assert meta.tags == ["database"] + assert meta.protect is True + assert "test_query_tool" in meta.module + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_tool_decorator_with_custom_metadata(): + """Test @tool decorator with custom name and description.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @tool( + name="Custom Tool", + description="Custom description", + tags=["test"], + protect=False, + ) + def my_tool() -> str: + """Original docstring.""" + return "test" + + meta = my_tool.__tool_metadata__ + assert meta.id == "my_tool" + assert meta.name == "Custom Tool" + assert meta.description == "Custom description" + assert meta.tags == ["test"] + assert meta.protect is False + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_prompt_decorator_stores_metadata_in_build_mode(): + """Test that @prompt decorator stores metadata when in BUILD mode.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @prompt(tags=["analysis"]) + async def test_prompt(ctx, dataset: str) -> str: + """Generate analysis for a dataset.""" + return f"Analyze {dataset}" + + # Should have metadata attached + assert hasattr(test_prompt, "__prompt_metadata__") + meta = test_prompt.__prompt_metadata__ + assert meta.id == "test_prompt" + assert meta.name == "test_prompt" + assert meta.description == "Generate analysis for a dataset." + assert meta.tags == ["analysis"] + assert meta.protect is True + assert "test_prompt" in meta.module + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_prompt_decorator_with_custom_metadata(): + """Test @prompt decorator with custom metadata.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + + @prompt( + name="Custom Prompt", + title="Custom Title", + description="Custom description", + tags=["custom"], + protect=False, + ) + async def my_prompt(ctx) -> str: + """Original docstring.""" + return "test" + + meta = my_prompt.__prompt_metadata__ + assert meta.id == "my_prompt" + assert meta.name == "Custom Prompt" + assert meta.title == "Custom Title" + assert meta.description == "Custom description" + assert meta.tags == ["custom"] + assert meta.protect is False + + finally: + ctx.set_mode(RegistrationMode.HOST) + + +def test_decorators_do_not_register_in_build_mode(): + """Test that decorators don't attempt registration in BUILD mode.""" + ctx = get_context() + ctx.set_mode(RegistrationMode.BUILD) + + try: + # This should not raise an exception even though no MCP server is available + @tool(tags=["test"]) + def build_mode_tool() -> str: + return "test" + + @prompt(tags=["test"]) + async def build_mode_prompt(ctx) -> str: + return "test" + + # Both should have metadata + assert hasattr(build_mode_tool, "__tool_metadata__") + assert hasattr(build_mode_prompt, "__prompt_metadata__") + + finally: + ctx.set_mode(RegistrationMode.HOST) diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 27b238d5241..f9eb8a6995e 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -84,10 +84,10 @@ def discover_backend_contributions( for pattern in files_patterns: py_files.extend(cwd.glob(pattern)) - for py_file in py_files: - if not py_file.is_file() or py_file.suffix != ".py": - continue + # Filter to only process Python files + python_files = [f for f in py_files if f.is_file() and f.suffix == ".py"] + for py_file in python_files: try: # Import module dynamically module = _import_module_from_path(py_file) @@ -154,6 +154,26 @@ def discover_backend_contributions( return contributions +def discover_frontend_contributions(cwd: Path) -> FrontendContributions: + """ + Discover frontend contributions from webpack plugin output. + + The webpack plugin outputs a contributions.json file during build. + """ + contributions_file = cwd / "frontend" / "dist" / "contributions.json" + + if not contributions_file.exists(): + # No frontend contributions found - this is normal for extensions without frontend + return FrontendContributions() + + try: + contributions_data = json.loads(contributions_file.read_text()) + return FrontendContributions.model_validate(contributions_data) + except Exception as e: + click.secho(f"⚠️ Failed to parse frontend contributions: {e}", fg="yellow") + return FrontendContributions() + + def _import_module_from_path(py_file: Path) -> Any: """Import a Python module from a file path.""" module_name = py_file.stem @@ -266,46 +286,48 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: extension = ExtensionConfig.model_validate(extension_data) - # Generate composite ID from publisher and name - composite_id = f"{extension.publisher}.{extension.name}" - - # Build frontend manifest + # Build frontend manifest with auto-discovery frontend: ManifestFrontend | None = None if extension.frontend and remote_entry: - # Use manually specified contributions if present, otherwise empty - # (frontend discovery will be added later) - frontend_contributions = ( - extension.frontend.contributions - if extension.frontend.contributions - else FrontendContributions() - ) + click.secho("🔍 Auto-discovering frontend contributions...", fg="cyan") + frontend_contributions = discover_frontend_contributions(cwd) + + # Count contributions for feedback + command_count = len(frontend_contributions.commands) + view_count = sum(len(views) for views in frontend_contributions.views.values()) + menu_count = len(frontend_contributions.menus) + editor_count = len(frontend_contributions.editors) + + total_count = command_count + view_count + menu_count + editor_count + if total_count > 0: + click.secho( + f" Found: {command_count} commands, {view_count} views, {menu_count} menus, {editor_count} editors", + fg="green", + ) + else: + click.secho(" No frontend contributions found", fg="yellow") + frontend = ManifestFrontend( contributions=frontend_contributions, moduleFederation=extension.frontend.moduleFederation, remoteEntry=remote_entry, ) - # Build backend manifest with contributions + # Build backend manifest with auto-discovered contributions backend: ManifestBackend | None = None if extension.backend: - # Use manually specified contributions if present, otherwise discover - if extension.backend.contributions: - backend_contributions = extension.backend.contributions - click.secho("📋 Using manually specified backend contributions", fg="cyan") - elif extension.backend.files: - click.secho("🔍 Discovering backend contributions...", fg="cyan") - backend_contributions = discover_backend_contributions( - cwd, extension.backend.files - ) - tool_count = len(backend_contributions.mcp_tools) - prompt_count = len(backend_contributions.mcp_prompts) - api_count = len(backend_contributions.rest_apis) - click.secho( - f" Found: {tool_count} tools, {prompt_count} prompts, {api_count} APIs", - fg="green", - ) - else: - backend_contributions = BackendContributions() + click.secho("🔍 Auto-discovering backend contributions...", fg="cyan") + backend_contributions = discover_backend_contributions( + cwd, extension.backend.files + ) + + tool_count = len(backend_contributions.mcp_tools) + prompt_count = len(backend_contributions.mcp_prompts) + api_count = len(backend_contributions.rest_apis) + click.secho( + f" Found: {tool_count} tools, {prompt_count} prompts, {api_count} APIs", + fg="green", + ) backend = ManifestBackend( entryPoints=extension.backend.entryPoints, diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2 index 08410b6d6d6..3954b5e1c7d 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2 +++ b/superset-extensions-cli/src/superset_extensions_cli/templates/extension.json.j2 @@ -6,12 +6,6 @@ "license": "{{ license }}", {% if include_frontend -%} "frontend": { - "contributions": { - "commands": [], - "views": {}, - "menus": {}, - "editors": [] - }, "moduleFederation": { "name": "{{ mf_name }}", "exposes": ["./index"] diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2 index be4d844aa25..b4e51d41094 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2 +++ b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/package.json.j2 @@ -19,6 +19,7 @@ "react-dom": "^17.0.2" }, "devDependencies": { + "@apache-superset/webpack-extension-plugin": "^0.0.1", "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@types/react": "^19.0.10", diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2 index b9a6266216d..f46d4d144c4 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2 +++ b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/src/index.tsx.j2 @@ -1,13 +1,32 @@ import React from "react"; -import { core } from "@apache-superset/core"; +import { defineCommand, defineView } from "@apache-superset/core"; -export const activate = (context: core.ExtensionContext) => { - context.disposables.push( - core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>) - ); - console.log("{{ name }} extension activated"); -}; +// Example command +export const exampleCommand = defineCommand({ + id: "example", + title: "Example Command", + icon: "ExperimentOutlined", + execute: async () => { + console.log("{{ name }} command executed!"); + }, + onActivate: () => { + console.log("Example command activated"); + }, + onDeactivate: () => { + console.log("Example command deactivated"); + }, +}); -export const deactivate = () => { - console.log("{{ name }} extension deactivated"); -}; +// Example view +export const exampleView = defineView({ + id: "example", + title: "{{ name }} View", + location: "explore.panels", // or dashboard.tabs, sqllab.panels, etc. + component: () => <div>Welcome to {{ name }}!</div>, + onActivate: () => { + console.log("Example view activated"); + }, + onDeactivate: () => { + console.log("Example view deactivated"); + }, +}); diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 index b3bf5139899..6793a68a351 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 +++ b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 @@ -1,5 +1,6 @@ const path = require("path"); const { ModuleFederationPlugin } = require("webpack").container; +const SupersetExtensionPlugin = require("@apache-superset/webpack-extension-plugin"); const packageConfig = require("./package"); module.exports = (env, argv) => { @@ -62,6 +63,12 @@ module.exports = (env, argv) => { }, }, }), + // Auto-discover and validate frontend contributions + new SupersetExtensionPlugin({ + outputPath: "contributions.json", + include: ["src/**/*.{ts,tsx,js,jsx}"], + exclude: ["**/*.test.*", "**/node_modules/**"], + }), ], }; }; diff --git a/superset-extensions-cli/tests/test_cli_build.py b/superset-extensions-cli/tests/test_cli_build.py index 94116e76b5a..5a59ddd676d 100644 --- a/superset-extensions-cli/tests/test_cli_build.py +++ b/superset-extensions-cli/tests/test_cli_build.py @@ -61,23 +61,15 @@ def extension_with_build_structure(): if include_frontend: extension_json["frontend"] = { - "contributions": { - "commands": [], - "views": {}, - "menus": {}, - "editors": [], - }, - "moduleFederation": { - "exposes": ["./index"], - "name": "testOrg_testExtension", - }, + "moduleFederation": {"exposes": ["./index"]}, } if include_backend: extension_json["backend"] = { "entryPoints": [ "superset_extensions.test_org.test_extension.entrypoint" - ] + ], + "files": ["backend/src/**/*.py"], } (base_path / "extension.json").write_text(json.dumps(extension_json)) @@ -250,19 +242,11 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): "permissions": ["read_data"], "dependencies": ["some_dep"], "frontend": { - "contributions": { - "commands": [{"id": "test_command", "title": "Test"}], - "views": {}, - "menus": {}, - "editors": [], - }, - "moduleFederation": { - "exposes": ["./index"], - "name": "testOrg_testExtension", - }, + "moduleFederation": {"exposes": ["./index"]}, }, "backend": { - "entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"] + "entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"], + "files": ["backend/src/**/*.py"], }, } extension_json = isolated_filesystem / "extension.json" @@ -279,15 +263,15 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem): assert manifest.permissions == ["read_data"] assert manifest.dependencies == ["some_dep"] - # Verify frontend section + # Verify frontend section (auto-discovery, currently empty) assert manifest.frontend is not None - assert manifest.frontend.contributions.commands == [ - {"id": "test_command", "title": "Test"} - ] + assert ( + manifest.frontend.contributions.commands == [] + ) # Auto-discovered (empty for now) assert manifest.frontend.moduleFederation.exposes == ["./index"] assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js" - # Verify backend section + # Verify backend section (auto-discovery) assert manifest.backend is not None assert manifest.backend.entryPoints == [ "superset_extensions.test_org.test_extension.entrypoint" diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 347e4edc0e9..9162978ce3e 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -470,6 +470,10 @@ "resolved": "packages/superset-core", "link": true }, + "node_modules/@apache-superset/webpack-extension-plugin": { + "resolved": "packages/webpack-extension-plugin", + "link": true + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -6072,7 +6076,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -14702,7 +14705,6 @@ "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*", @@ -14713,7 +14715,6 @@ "version": "3.7.7", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, "license": "MIT", "dependencies": { "@types/eslint": "*", @@ -14724,7 +14725,6 @@ "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/expect": { @@ -17224,7 +17224,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", @@ -17235,28 +17234,24 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", @@ -17268,14 +17263,12 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17288,7 +17281,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" @@ -17298,7 +17290,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" @@ -17308,14 +17299,12 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17332,7 +17321,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17346,7 +17334,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17359,7 +17346,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17374,7 +17360,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.14.1", @@ -17432,14 +17417,12 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@yarnpkg/lockfile": { @@ -17931,7 +17914,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" @@ -19273,7 +19255,6 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -19706,7 +19687,6 @@ "version": "1.0.30001764", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", - "dev": true, "funding": [ { "type": "opencollective", @@ -19997,7 +19977,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0" @@ -23871,7 +23850,6 @@ "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, "license": "ISC" }, "node_modules/emittery": { @@ -23963,7 +23941,6 @@ "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -25289,7 +25266,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -25303,7 +25279,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -25621,7 +25596,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -28059,7 +28033,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/global-dirs": { @@ -36195,7 +36168,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" @@ -37468,7 +37440,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -37478,7 +37449,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -38227,7 +38197,6 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, "license": "MIT" }, "node_modules/nomnom": { @@ -41368,7 +41337,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -44937,7 +44905,6 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -45064,7 +45031,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -45711,7 +45677,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -45722,7 +45687,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -46941,7 +46905,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -47094,7 +47057,6 @@ "version": "5.37.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -47113,7 +47075,6 @@ "version": "5.3.16", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -47148,7 +47109,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -47163,7 +47123,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -47179,7 +47138,6 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -47192,7 +47150,6 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, "license": "MIT" }, "node_modules/test-exclude": { @@ -49185,7 +49142,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -49884,7 +49840,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "dev": true, "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -50565,14 +50520,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/webpack/node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -50585,7 +50538,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" @@ -50598,14 +50550,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, "license": "MIT" }, "node_modules/webpack/node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/websocket-driver": { @@ -53013,6 +52963,20 @@ "version": "0.20.3", "license": "Apache-2.0" }, + "packages/webpack-extension-plugin": { + "name": "@apache-superset/webpack-extension-plugin", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "typescript": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^25.1.0" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "plugins/legacy-plugin-chart-calendar": { "name": "@superset-ui/legacy-plugin-chart-calendar", "version": "0.20.3", diff --git a/superset-frontend/packages/superset-core/src/extensions/index.ts b/superset-frontend/packages/superset-core/src/extensions/index.ts new file mode 100644 index 00000000000..0a38093e427 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/extensions/index.ts @@ -0,0 +1,326 @@ +/** + * 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. + */ + +// Contribution configuration interfaces +export interface CommandConfig { + id: string; + title: string; + icon?: string; + execute: () => void | Promise<void>; + when?: () => boolean; + onActivate?: () => void; // Called when command is registered + onDeactivate?: () => void; // Called when command is unregistered +} + +export interface ViewConfig { + id: string; + title: string; + location: string; // e.g., "dashboard.tabs", "explore.panels" + component: React.ComponentType; + when?: () => boolean; + onActivate?: () => void; // Called when view is registered + onDeactivate?: () => void; // Called when view is unregistered +} + +export interface EditorConfig { + id: string; + name: string; + mimeTypes: string[]; + component: React.ComponentType; + onActivate?: () => void; // Called when editor is registered + onDeactivate?: () => void; // Called when editor is unregistered +} + +export interface MenuConfig { + id: string; + title: string; + location: string; // e.g., "navbar.items", "context.menus" + action: () => void | Promise<void>; + when?: () => boolean; + onActivate?: () => void; // Called when menu item is registered + onDeactivate?: () => void; // Called when menu item is unregistered +} + +// Extension metadata attached to defined contributions +export interface ContributionMetadata { + type: 'command' | 'view' | 'editor' | 'menu'; + id: string; + config: any; +} + +// Handle returned by define* functions for cleanup +export interface ContributionHandle<T = any> { + config: T; + dispose: () => void; + __contributionMeta__: ContributionMetadata; +} + +// Extension context interface (simplified) +export interface ExtensionContext { + registerCommand: (config: CommandConfig) => () => void; + registerViewProvider: ( + id: string, + component: React.ComponentType, + ) => () => void; + registerEditor: (config: EditorConfig) => () => void; + registerMenu: (config: MenuConfig) => () => void; +} + +// Global registry for auto-registration +let _context: ExtensionContext | null = null; +const _pendingContributions: ContributionHandle[] = []; + +/** + * Set the extension context for auto-registration. + * Called automatically by the extension loader. + */ +export function setExtensionContext(context: ExtensionContext): void { + _context = context; + + // Auto-register any pending contributions + for (const handle of _pendingContributions) { + _registerContribution(handle); + } + _pendingContributions.length = 0; +} + +/** + * Internal: Auto-register a single contribution + */ +function _registerContribution(handle: ContributionHandle): void { + if (!_context) { + _pendingContributions.push(handle); + return; + } + + const { config, __contributionMeta__ } = handle; + let disposeFn: () => void; + + // Call onActivate callback if provided + const typedConfig = config as + | CommandConfig + | ViewConfig + | EditorConfig + | MenuConfig; + if (typedConfig.onActivate) { + typedConfig.onActivate(); + } + + switch (__contributionMeta__.type) { + case 'command': + disposeFn = _context.registerCommand(config as CommandConfig); + break; + case 'view': + const viewConfig = config as ViewConfig; + disposeFn = _context.registerViewProvider( + viewConfig.id, + viewConfig.component, + ); + break; + case 'editor': + disposeFn = _context.registerEditor(config as EditorConfig); + break; + case 'menu': + disposeFn = _context.registerMenu(config as MenuConfig); + break; + default: + throw new Error( + `Unknown contribution type: ${__contributionMeta__.type}`, + ); + } + + // Wrap dispose function to call onDeactivate + const originalDispose = disposeFn; + handle.dispose = () => { + if (typedConfig.onDeactivate) { + typedConfig.onDeactivate(); + } + originalDispose(); + }; +} + +// Type augmentation to add metadata to functions +declare global { + interface Function { + __contributionMeta__?: ContributionMetadata; + } +} + +/** + * Define a command contribution. + * + * Commands are actions that can be triggered from various UI elements + * like menus, toolbars, or keyboard shortcuts. + * + * Auto-registers when extension context is available. + * + * @param config Command configuration + * @returns Handle with config and dispose function + */ +export function defineCommand<T extends CommandConfig>( + config: T, +): ContributionHandle<T> { + // Store metadata for webpack plugin discovery + const metadata: ContributionMetadata = { + type: 'command', + id: config.id, + config, + }; + + // Attach metadata to the execute function for runtime validation + if (config.execute) { + config.execute.__contributionMeta__ = metadata; + } + + // Create handle that auto-registers + const handle: ContributionHandle<T> = { + config, + dispose: () => {}, // Will be set by _registerContribution + __contributionMeta__: metadata, + }; + + // Auto-register immediately or queue for later + _registerContribution(handle); + + return handle; +} + +/** + * Define a view contribution. + * + * Views are UI components that can be embedded in various locations + * throughout the Superset interface. + * + * Auto-registers when extension context is available. + * + * @param config View configuration + * @returns Handle with config and dispose function + */ +export function defineView<T extends ViewConfig>( + config: T, +): ContributionHandle<T> { + // Store metadata for webpack plugin discovery + const metadata: ContributionMetadata = { + type: 'view', + id: config.id, + config, + }; + + // Attach metadata to the component for runtime validation + if (config.component) { + (config.component as any).__contributionMeta__ = metadata; + } + + // Create handle that auto-registers + const handle: ContributionHandle<T> = { + config, + dispose: () => {}, // Will be set by _registerContribution + __contributionMeta__: metadata, + }; + + // Auto-register immediately or queue for later + _registerContribution(handle); + + return handle; +} + +/** + * Define an editor contribution. + * + * Editors provide custom editing interfaces for specific MIME types + * in SQL Lab and other contexts. + * + * Auto-registers when extension context is available. + * + * @param config Editor configuration + * @returns Handle with config and dispose function + */ +export function defineEditor<T extends EditorConfig>( + config: T, +): ContributionHandle<T> { + // Store metadata for webpack plugin discovery + const metadata: ContributionMetadata = { + type: 'editor', + id: config.id, + config, + }; + + // Attach metadata to the component for runtime validation + if (config.component) { + (config.component as any).__contributionMeta__ = metadata; + } + + // Create handle that auto-registers + const handle: ContributionHandle<T> = { + config, + dispose: () => {}, // Will be set by _registerContribution + __contributionMeta__: metadata, + }; + + // Auto-register immediately or queue for later + _registerContribution(handle); + + return handle; +} + +/** + * Define a menu contribution. + * + * Menus add items to various menu locations throughout the interface. + * + * Auto-registers when extension context is available. + * + * @param config Menu configuration + * @returns Handle with config and dispose function + */ +export function defineMenu<T extends MenuConfig>( + config: T, +): ContributionHandle<T> { + // Store metadata for webpack plugin discovery + const metadata: ContributionMetadata = { + type: 'menu', + id: config.id, + config, + }; + + // Attach metadata to the action function for runtime validation + if (config.action) { + config.action.__contributionMeta__ = metadata; + } + + // Create handle that auto-registers + const handle: ContributionHandle<T> = { + config, + dispose: () => {}, // Will be set by _registerContribution + __contributionMeta__: metadata, + }; + + // Auto-register immediately or queue for later + _registerContribution(handle); + + return handle; +} + +/** + * Internal: Clear the contribution registry (for testing) + */ +export function _clearContributionRegistry(): void { + _pendingContributions.length = 0; + _context = null; +} diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index b02156449f2..5f2a95d94c7 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -17,5 +17,6 @@ * under the License. */ export * from './api'; +export * from './extensions'; export * from './ui'; export * from './utils'; diff --git a/superset-frontend/packages/superset-core/test/extensions.test.ts b/superset-frontend/packages/superset-core/test/extensions.test.ts new file mode 100644 index 00000000000..950eb775c29 --- /dev/null +++ b/superset-frontend/packages/superset-core/test/extensions.test.ts @@ -0,0 +1,253 @@ +/** + * 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 * as React from 'react'; +import { + defineCommand, + defineView, + defineEditor, + defineMenu, + setExtensionContext, + _clearContributionRegistry, +} from '../src/extensions'; + +// Mock extension context for testing +const mockContext = { + registerCommand: jest.fn(() => jest.fn()), + registerViewProvider: jest.fn(() => jest.fn()), + registerEditor: jest.fn(() => jest.fn()), + registerMenu: jest.fn(() => jest.fn()), +}; + +describe('Extension Contributions', () => { + beforeEach(() => { + _clearContributionRegistry(); + jest.clearAllMocks(); + }); + + describe('defineCommand', () => { + test('should create command contribution with metadata', () => { + const command = defineCommand({ + id: 'test-command', + title: 'Test Command', + icon: 'TestIcon', + execute: async () => console.log('executed'), + }); + + expect(command.config.id).toBe('test-command'); + expect(command.config.title).toBe('Test Command'); + expect(command.config.icon).toBe('TestIcon'); + expect(command.__contributionMeta__).toBeDefined(); + expect(command.__contributionMeta__.type).toBe('command'); + expect(command.__contributionMeta__.id).toBe('test-command'); + }); + + test('should auto-register when context is available', () => { + setExtensionContext(mockContext); + + const command = defineCommand({ + id: 'auto-command', + title: 'Auto Command', + execute: async () => {}, + }); + + expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config); + }); + + test('should call lifecycle callbacks', () => { + const onActivate = jest.fn(); + const onDeactivate = jest.fn(); + + setExtensionContext(mockContext); + + const command = defineCommand({ + id: 'lifecycle-command', + title: 'Lifecycle Command', + execute: async () => {}, + onActivate, + onDeactivate, + }); + + expect(onActivate).toHaveBeenCalled(); + + // Test disposal + command.dispose(); + expect(onDeactivate).toHaveBeenCalled(); + }); + }); + + describe('defineView', () => { + test('should create view contribution with metadata', () => { + const TestComponent = () => React.createElement('div', null, 'Test'); + + const view = defineView({ + id: 'test-view', + title: 'Test View', + location: 'sqllab.panels', + component: TestComponent, + }); + + expect(view.config.id).toBe('test-view'); + expect(view.config.title).toBe('Test View'); + expect(view.config.location).toBe('sqllab.panels'); + expect(view.config.component).toBe(TestComponent); + expect(view.__contributionMeta__).toBeDefined(); + expect(view.__contributionMeta__.type).toBe('view'); + }); + + test('should auto-register when context is available', () => { + const TestComponent = () => React.createElement('div', null, 'Test'); + + setExtensionContext(mockContext); + + defineView({ + id: 'auto-view', + title: 'Auto View', + location: 'dashboard.tabs', + component: TestComponent, + }); + + expect(mockContext.registerViewProvider).toHaveBeenCalledWith( + 'auto-view', + TestComponent + ); + }); + }); + + describe('defineEditor', () => { + test('should create editor contribution with metadata', () => { + const EditorComponent = () => React.createElement('textarea'); + + const editor = defineEditor({ + id: 'test-editor', + name: 'Test Editor', + mimeTypes: ['text/x-sql'], + component: EditorComponent, + }); + + expect(editor.config.id).toBe('test-editor'); + expect(editor.config.name).toBe('Test Editor'); + expect(editor.config.mimeTypes).toEqual(['text/x-sql']); + expect(editor.__contributionMeta__).toBeDefined(); + expect(editor.__contributionMeta__.type).toBe('editor'); + }); + + test('should auto-register when context is available', () => { + const EditorComponent = () => React.createElement('textarea'); + + setExtensionContext(mockContext); + + const editor = defineEditor({ + id: 'auto-editor', + name: 'Auto Editor', + mimeTypes: ['text/plain'], + component: EditorComponent, + }); + + expect(mockContext.registerEditor).toHaveBeenCalledWith(editor.config); + }); + }); + + describe('defineMenu', () => { + test('should create menu contribution with metadata', () => { + const menu = defineMenu({ + id: 'test-menu', + title: 'Test Menu', + location: 'navbar.items', + action: () => console.log('clicked'), + }); + + expect(menu.config.id).toBe('test-menu'); + expect(menu.config.title).toBe('Test Menu'); + expect(menu.config.location).toBe('navbar.items'); + expect(menu.__contributionMeta__).toBeDefined(); + expect(menu.__contributionMeta__.type).toBe('menu'); + }); + + test('should auto-register when context is available', () => { + setExtensionContext(mockContext); + + const menu = defineMenu({ + id: 'auto-menu', + title: 'Auto Menu', + location: 'context.menus', + action: () => {}, + }); + + expect(mockContext.registerMenu).toHaveBeenCalledWith(menu.config); + }); + }); + + describe('Auto-registration system', () => { + test('should queue contributions when no context is set', () => { + const command = defineCommand({ + id: 'queued-command', + title: 'Queued Command', + execute: async () => {}, + }); + + // Should not be registered yet + expect(mockContext.registerCommand).not.toHaveBeenCalled(); + + // Set context - should register queued contributions + setExtensionContext(mockContext); + expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config); + }); + + test('should handle disposal correctly', () => { + const mockDispose = jest.fn(); + mockContext.registerCommand.mockReturnValue(mockDispose); + + setExtensionContext(mockContext); + + const command = defineCommand({ + id: 'dispose-command', + title: 'Dispose Command', + execute: async () => {}, + }); + + // Dispose should call the returned cleanup function + command.dispose(); + expect(mockDispose).toHaveBeenCalled(); + }); + + test('should handle mixed contribution types', () => { + setExtensionContext(mockContext); + + const command = defineCommand({ + id: 'mixed-command', + title: 'Mixed Command', + execute: async () => {}, + }); + + const view = defineView({ + id: 'mixed-view', + title: 'Mixed View', + location: 'explore.panels', + component: () => React.createElement('div', null, 'Mixed'), + }); + + expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config); + expect(mockContext.registerViewProvider).toHaveBeenCalledWith( + 'mixed-view', + view.config.component + ); + }); + }); +}); diff --git a/superset-frontend/packages/webpack-extension-plugin/package.json b/superset-frontend/packages/webpack-extension-plugin/package.json new file mode 100644 index 00000000000..24b3bd03df5 --- /dev/null +++ b/superset-frontend/packages/webpack-extension-plugin/package.json @@ -0,0 +1,45 @@ +{ + "name": "@apache-superset/webpack-extension-plugin", + "version": "0.0.1", + "description": "Webpack plugin for processing and validating Superset extension contributions", + "keywords": [ + "superset", + "webpack", + "plugin", + "extensions" + ], + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/packages/webpack-extension-plugin#readme", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/packages/webpack-contribution-plugin" + }, + "license": "Apache-2.0", + "author": "Superset", + "sideEffects": false, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "clean": "rm -rf lib tsconfig.tsbuildinfo", + "build": "npm run clean && npx tsc --build", + "type": "npx tsc --noEmit" + }, + "dependencies": { + "typescript": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^25.1.0" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/superset-frontend/packages/webpack-extension-plugin/src/index.ts b/superset-frontend/packages/webpack-extension-plugin/src/index.ts new file mode 100644 index 00000000000..0052d9045d8 --- /dev/null +++ b/superset-frontend/packages/webpack-extension-plugin/src/index.ts @@ -0,0 +1,532 @@ +/** + * 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 * as ts from 'typescript'; +import * as path from 'path'; +import * as fs from 'fs'; +import { Compiler, WebpackPluginInstance, sources, Compilation } from 'webpack'; + +// Contribution type definitions matching backend schema +interface CommandContribution { + id: string; + title: string; + icon?: string; + execute: string; // Module path to the execute function +} + +interface ViewContribution { + id: string; + title: string; + component: string; // Module path to the component + location: string; // e.g., "dashboard.tabs", "explore.panels" +} + +interface EditorContribution { + id: string; + name: string; + component: string; // Module path to the component + mimeTypes: string[]; +} + +interface MenuContribution { + id: string; + title: string; + location: string; // e.g., "navbar.items", "context.menus" + action: string; // Module path to the action +} + +interface FrontendContributions { + commands: CommandContribution[]; + views: Record<string, ViewContribution[]>; // Grouped by location + editors: EditorContribution[]; + menus: Record<string, MenuContribution[]>; // Grouped by location +} + +interface SupersetExtensionPluginOptions { + outputPath?: string; // Where to write contributions.json + include?: string[]; // File patterns to include + exclude?: string[]; // File patterns to exclude +} + +/** + * Webpack plugin for auto-discovering Superset extension contributions. + * + * Analyzes TypeScript/JavaScript files during compilation to find calls to + * define* functions from @apache-superset/core and outputs a contributions.json + * file with the discovered contributions. + */ +export default class SupersetContributionPlugin implements WebpackPluginInstance { + private options: SupersetExtensionPluginOptions; + + constructor(options: SupersetExtensionPluginOptions = {}) { + this.options = { + outputPath: 'contributions.json', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: ['**/*.test.*', '**/node_modules/**'], + ...options, + }; + } + + apply(compiler: Compiler): void { + compiler.hooks.compilation.tap( + 'SupersetContributionPlugin', + compilation => { + compilation.hooks.processAssets.tap( + { + name: 'SupersetContributionPlugin', + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + try { + const contributions = this.discoverContributions( + compiler.context, + ); + const contributionsJson = JSON.stringify(contributions, null, 2); + + // Add the contributions.json file to webpack's output + const outputPath = this.options.outputPath!; + compilation.emitAsset( + outputPath, + new sources.RawSource(contributionsJson), + ); + + console.log( + `🔍 Discovered ${this.countContributions(contributions)} frontend contributions`, + ); + } catch (error) { + compilation.errors.push( + new Error(`SupersetContributionPlugin: ${error}`), + ); + } + }, + ); + }, + ); + } + + private discoverContributions(rootPath: string): FrontendContributions { + const contributions: FrontendContributions = { + commands: [], + views: {}, + editors: [], + menus: {}, + }; + + // Find all TypeScript/JavaScript files + const files = this.findSourceFiles(rootPath); + + for (const filePath of files) { + try { + const fileContributions = this.analyzeFile(filePath); + this.mergeContributions(contributions, fileContributions); + } catch (error) { + console.warn(`⚠️ Failed to analyze ${filePath}:`, error); + } + } + + return contributions; + } + + private findSourceFiles(rootPath: string): string[] { + const files: string[] = []; + const { include, exclude } = this.options; + + const walkDir = (dir: string) => { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path + .relative(rootPath, fullPath) + .replace(/\\/g, '/'); // Normalize path separators + + if (entry.isDirectory()) { + // Skip excluded directories and node_modules + if (entry.name === 'node_modules') { + continue; + } + const isExcluded = this.matchesPatterns(relativePath, exclude!); + if (!isExcluded) { + walkDir(fullPath); + } + } else if (entry.isFile()) { + // Only check files that might be relevant (have extensions we care about) + const hasRelevantExtension = /\.(ts|tsx|js|jsx)$/.test( + relativePath, + ); + if (hasRelevantExtension) { + const isIncluded = this.matchesPatterns(relativePath, include!); + const isExcluded = this.matchesPatterns(relativePath, exclude!); + + if (isIncluded && !isExcluded) { + files.push(fullPath); + } + } + } + } + } catch (error) { + // Skip directories that can't be read + } + }; + + walkDir(rootPath); + return files; + } + + private matchesPatterns(filePath: string, patterns: string[]): boolean { + return patterns.some(pattern => { + // Handle brace expansion like {ts,tsx,js,jsx} + const expandedPatterns = this.expandBraces(pattern); + return expandedPatterns.some(expandedPattern => { + // Convert glob pattern to regex + // Special handling for **/pattern which should match pattern directly + let regex = expandedPattern + // Replace glob patterns first + .replace(/\*\*\/\*/g, '§DOUBLESTAR_SLASH_STAR§') // **/* becomes special pattern + .replace(/\*\*/g, '§DOUBLESTAR§') // ** becomes another pattern + .replace(/\*/g, '§STAR§') // * becomes yet another + .replace(/\?/g, '§QUESTION§') // ? becomes final pattern + // Then escape all regex special chars + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + // Then restore glob patterns as regex + .replace(/§DOUBLESTAR_SLASH_STAR§/g, '(?:.*/)?[^/]*') // **/* matches zero+ dirs + filename + .replace(/§DOUBLESTAR§/g, '(?:.*)?') // ** matches zero+ chars + .replace(/§STAR§/g, '[^/]*') // * matches anything except / + .replace(/§QUESTION§/g, '.'); // ? matches any single char + + const regexPattern = new RegExp(`^${regex}$`); + const matches = regexPattern.test(filePath); + + return matches; + }); + }); + } + + private expandBraces(pattern: string): string[] { + const braceMatch = pattern.match(/^(.+)\{([^}]+)\}(.*)$/); + if (!braceMatch) { + return [pattern]; + } + + const [, prefix, options, suffix] = braceMatch; + return options.split(',').map(option => prefix + option.trim() + suffix); + } + + private analyzeFile(filePath: string): FrontendContributions { + const sourceCode = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + sourceCode, + ts.ScriptTarget.Latest, + true, + ); + + const contributions: FrontendContributions = { + commands: [], + views: {}, + editors: [], + menus: {}, + }; + + // Create a TypeScript program for type checking + const program = ts.createProgram([filePath], { + target: ts.ScriptTarget.Latest, + module: ts.ModuleKind.ESNext, + allowJs: true, + jsx: ts.JsxEmit.React, + }); + + const checker = program.getTypeChecker(); + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + this.analyzeCallExpression(node, checker, contributions, filePath); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return contributions; + } + + private analyzeCallExpression( + call: ts.CallExpression, + checker: ts.TypeChecker, + contributions: FrontendContributions, + filePath: string, + ): void { + if (!ts.isIdentifier(call.expression)) { + return; + } + + const functionName = call.expression.text; + + // Check if this is a define* function call + if ( + !['defineCommand', 'defineView', 'defineEditor', 'defineMenu'].includes( + functionName, + ) + ) { + return; + } + + // For externals (like @apache-superset/core), validate by checking import statements + const isValidPackage = this.isValidImportInFile(filePath, functionName); + + if (!isValidPackage) { + console.warn( + `⚠️ ${functionName} not imported from @apache-superset/core in ${filePath}`, + ); + return; + } + + // Extract the configuration object + if (call.arguments.length === 0) { + return; + } + + const configArg = call.arguments[0]; + const config = this.extractObjectLiteral(configArg); + + if (!config) { + console.warn( + `⚠️ Could not extract config from ${functionName} call in ${filePath}`, + ); + return; + } + + // Process the contribution based on its type + switch (functionName) { + case 'defineCommand': + this.processCommandContribution(config, contributions, filePath); + break; + case 'defineView': + this.processViewContribution(config, contributions, filePath); + break; + case 'defineEditor': + this.processEditorContribution(config, contributions, filePath); + break; + case 'defineMenu': + this.processMenuContribution(config, contributions, filePath); + break; + } + } + + private isValidImportInFile(filePath: string, functionName: string): boolean { + try { + const sourceCode = fs.readFileSync(filePath, 'utf-8'); + + // Check for import statements that import our function from @apache-superset/core + const importRegex = new RegExp( + `import\\s+{[^}]*\\b${functionName}\\b[^}]*}\\s+from\\s+['"]@apache-superset/core['"]`, + ); + + const hasValidImport = importRegex.test(sourceCode); + + if (hasValidImport) { + return true; + } + + // Also check for default imports or namespace imports + const defaultImportRegex = + /import\s+\w+\s+from\s+['"]@apache-superset\/core['"]/; + const namespaceImportRegex = + /import\s+\*\s+as\s+\w+\s+from\s+['"]@apache-superset\/core['"]/; + + return ( + defaultImportRegex.test(sourceCode) || + namespaceImportRegex.test(sourceCode) + ); + } catch (error) { + return false; + } + } + + private extractObjectLiteral(node: ts.Node): Record<string, any> | null { + if (!ts.isObjectLiteralExpression(node)) { + return null; + } + + const result: Record<string, any> = {}; + + for (const property of node.properties) { + if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name)) { + const key = property.name.text; + const value = this.extractValue(property.initializer); + result[key] = value; + } + } + + return result; + } + + private extractValue(node: ts.Node): any { + if (ts.isStringLiteral(node)) { + return node.text; + } else if (ts.isNumericLiteral(node)) { + return parseFloat(node.text); + } else if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } else if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } else if (ts.isArrayLiteralExpression(node)) { + return node.elements.map(element => this.extractValue(element)); + } else if (ts.isObjectLiteralExpression(node)) { + return this.extractObjectLiteral(node); + } + + // For complex expressions, return a string representation + return node.getText(); + } + + private processCommandContribution( + config: Record<string, any>, + contributions: FrontendContributions, + filePath: string, + ): void { + if (!config.id || !config.title) { + console.warn( + `⚠️ Command contribution missing required fields in ${filePath}`, + ); + return; + } + + contributions.commands.push({ + id: config.id, + title: config.title, + icon: config.icon, + execute: this.getModulePath(filePath), + }); + } + + private processViewContribution( + config: Record<string, any>, + contributions: FrontendContributions, + filePath: string, + ): void { + if (!config.id || !config.title || !config.location) { + console.warn( + `⚠️ View contribution missing required fields in ${filePath}`, + ); + return; + } + + const { location } = config; + if (!contributions.views[location]) { + contributions.views[location] = []; + } + + contributions.views[location].push({ + id: config.id, + title: config.title, + component: this.getModulePath(filePath), + location, + }); + } + + private processEditorContribution( + config: Record<string, any>, + contributions: FrontendContributions, + filePath: string, + ): void { + if (!config.id || !config.name || !config.mimeTypes) { + console.warn( + `⚠️ Editor contribution missing required fields in ${filePath}`, + ); + return; + } + + contributions.editors.push({ + id: config.id, + name: config.name, + component: this.getModulePath(filePath), + mimeTypes: Array.isArray(config.mimeTypes) + ? config.mimeTypes + : [config.mimeTypes], + }); + } + + private processMenuContribution( + config: Record<string, any>, + contributions: FrontendContributions, + filePath: string, + ): void { + if (!config.id || !config.title || !config.location) { + console.warn( + `⚠️ Menu contribution missing required fields in ${filePath}`, + ); + return; + } + + const { location } = config; + if (!contributions.menus[location]) { + contributions.menus[location] = []; + } + + contributions.menus[location].push({ + id: config.id, + title: config.title, + location, + action: this.getModulePath(filePath), + }); + } + + private getModulePath(filePath: string): string { + // Convert absolute file path to a module path relative to src/ + const srcIndex = filePath.lastIndexOf('/src/'); + if (srcIndex !== -1) { + return filePath.substring(srcIndex + 5).replace(/\.(ts|tsx|js|jsx)$/, ''); + } + return filePath; + } + + private mergeContributions( + target: FrontendContributions, + source: FrontendContributions, + ): void { + target.commands.push(...source.commands); + target.editors.push(...source.editors); + + // Merge views by location + for (const [location, views] of Object.entries(source.views)) { + if (!target.views[location]) { + target.views[location] = []; + } + target.views[location].push(...views); + } + + // Merge menus by location + for (const [location, menus] of Object.entries(source.menus)) { + if (!target.menus[location]) { + target.menus[location] = []; + } + target.menus[location].push(...menus); + } + } + + private countContributions(contributions: FrontendContributions): number { + return ( + contributions.commands.length + + contributions.editors.length + + Object.values(contributions.views).flat().length + + Object.values(contributions.menus).flat().length + ); + } +} diff --git a/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts b/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts new file mode 100644 index 00000000000..dd5a38745ff --- /dev/null +++ b/superset-frontend/packages/webpack-extension-plugin/test/index.test.ts @@ -0,0 +1,148 @@ +/** + * 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 SupersetContributionPlugin from '../src/index'; + +// Mock webpack compiler and compilation +const createMockCompiler = (outputPath: string = '/test/output') => { + return { + options: { + output: { + path: outputPath, + }, + }, + hooks: { + compilation: { + tap: jest.fn(), + }, + emit: { + tap: jest.fn(), + }, + }, + } as any; +}; + +const createMockCompilation = (assets: Record<string, any> = {}) => { + return { + assets, + hooks: { + processAssets: { + tap: jest.fn(), + }, + }, + emitAsset: jest.fn(), + } as any; +}; + +describe('SupersetContributionPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + test('should create plugin with default options', () => { + const plugin = new SupersetContributionPlugin(); + + // Test that plugin was created (can't access private options easily) + expect(plugin).toBeInstanceOf(SupersetContributionPlugin); + }); + + test('should create plugin with custom options', () => { + const plugin = new SupersetContributionPlugin({ + outputPath: 'custom.json', + include: ['app/**/*.ts'], + exclude: ['**/*.spec.*'], + }); + + expect(plugin).toBeInstanceOf(SupersetContributionPlugin); + }); + }); + + describe('apply', () => { + test('should register emit hook', () => { + const plugin = new SupersetContributionPlugin(); + const compiler = createMockCompiler(); + + plugin.apply(compiler); + + expect(compiler.hooks.emit.tapAsync).toHaveBeenCalledWith( + 'SupersetContributionPlugin', + expect.any(Function), + ); + }); + }); + + describe('integration test', () => { + test('should process and emit contributions.json', done => { + const plugin = new SupersetContributionPlugin(); + const compiler = createMockCompiler('/test/root'); + const compilation = createMockCompilation(); + + // Mock the emit hook to be called synchronously for testing + compiler.hooks.emit.tapAsync.mockImplementation( + (name: string, callback: any) => { + // Call the callback with mocked compilation + try { + callback(compilation, () => { + // Verify contributions.json was emitted + expect(compilation.assets['contributions.json']).toBeDefined(); + + const asset = compilation.assets['contributions.json']; + const content = asset.source(); + const contributions = JSON.parse(content); + + // Should have the expected structure + expect(contributions).toHaveProperty('commands'); + expect(contributions).toHaveProperty('views'); + expect(contributions).toHaveProperty('editors'); + expect(contributions).toHaveProperty('menus'); + + expect(Array.isArray(contributions.commands)).toBe(true); + expect(typeof contributions.views).toBe('object'); + expect(Array.isArray(contributions.editors)).toBe(true); + expect(typeof contributions.menus).toBe('object'); + + done(); + }); + } catch (error) { + done(error); + } + }, + ); + + plugin.apply(compiler); + }); + }); + + describe('file pattern matching', () => { + test('should match TypeScript files', () => { + const plugin = new SupersetContributionPlugin(); + + // Test that the plugin handles common file patterns + expect(plugin).toBeInstanceOf(SupersetContributionPlugin); + }); + + test('should exclude test files', () => { + const plugin = new SupersetContributionPlugin(); + + // Test that the plugin excludes test patterns + expect(plugin).toBeInstanceOf(SupersetContributionPlugin); + }); + }); +}); diff --git a/superset-frontend/packages/webpack-extension-plugin/tsconfig.json b/superset-frontend/packages/webpack-extension-plugin/tsconfig.json new file mode 100644 index 00000000000..8157eef27f6 --- /dev/null +++ b/superset-frontend/packages/webpack-extension-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "dom"], + "module": "CommonJS", + "declaration": true, + "outDir": "./lib", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "**/*.test.ts"] +} diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 74c5ee05fd7..21c129fb66a 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -31,7 +31,7 @@ import { SqlLabRootState } from 'src/SqlLab/types'; import { ViewLocations } from 'src/SqlLab/contributions'; import PanelToolbar from 'src/components/PanelToolbar'; import { useExtensionsContext } from 'src/extensions/ExtensionsContext'; -import ExtensionsManager from 'src/extensions/ExtensionsManager'; +import ExtensionLoader from 'src/extensions/ExtensionLoader'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import useLogAction from 'src/logger/useLogAction'; import { LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB } from 'src/logger/LogUtils'; @@ -105,9 +105,7 @@ const SouthPane = ({ const theme = useTheme(); const dispatch = useDispatch(); const contributions = - ExtensionsManager.getInstance().getViewContributions( - ViewLocations.sqllab.panels, - ) || []; + ExtensionLoader.getInstance().getViewContributions('sqllab.panels') || []; const { getView } = useExtensionsContext(); const { offline, tables } = useSelector( ({ sqlLab: { offline, tables } }: SqlLabRootState) => ({ diff --git a/superset-frontend/src/extensions/ExtensionLoader.ts b/superset-frontend/src/extensions/ExtensionLoader.ts new file mode 100644 index 00000000000..42b4c3effe0 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionLoader.ts @@ -0,0 +1,470 @@ +/** + * 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 { SupersetClient } from '@superset-ui/core'; +import { logging } from '@apache-superset/core'; +import { setExtensionContext } from '@apache-superset/core'; +import type { ExtensionContext } from '@apache-superset/core'; + +// Manifest schema from auto-discovery +interface ManifestFrontend { + remoteEntry: string; + contributions: FrontendContributions; +} + +interface FrontendContributions { + commands: CommandContribution[]; + views: Record<string, ViewContribution[]>; + editors: EditorContribution[]; + menus: Record<string, MenuContribution[]>; +} + +interface CommandContribution { + id: string; + title: string; + icon?: string; + execute: string; // Module path +} + +interface ViewContribution { + id: string; + title: string; + component: string; // Module path + location: string; +} + +interface EditorContribution { + id: string; + name: string; + component: string; // Module path + mimeTypes: string[]; +} + +interface MenuContribution { + id: string; + title: string; + location: string; + action: string; // Module path +} + +interface ExtensionManifest { + id: string; + name: string; + version: string; + frontend?: ManifestFrontend; +} + +interface LoadedExtension { + id: string; + name: string; + manifest: ExtensionManifest; + module?: any; // The loaded webpack module + disposables: Array<() => void>; +} + +/** + * ExtensionLoader - Loads and validates extensions using the auto-discovery system + */ +class ExtensionLoader { + private static instance: ExtensionLoader; + private loadedExtensions: Map<string, LoadedExtension> = new Map(); + private contributionRegistry: Map<string, any> = new Map(); // Track registered contributions + + private constructor() {} + + public static getInstance(): ExtensionLoader { + if (!ExtensionLoader.instance) { + ExtensionLoader.instance = new ExtensionLoader(); + } + return ExtensionLoader.instance; + } + + /** + * Initialize and load all available extensions + */ + public async initializeExtensions(): Promise<void> { + try { + const response = await SupersetClient.get({ + endpoint: '/api/v1/extensions/', + }); + + const extensions: ExtensionManifest[] = response.json.result; + + await Promise.all( + extensions.map(async manifest => { + try { + await this.loadExtension(manifest); + } catch (error) { + logging.error(`Failed to load extension ${manifest.id}:`, error); + } + }), + ); + + logging.info( + `Loaded ${this.loadedExtensions.size} extensions successfully`, + ); + } catch (error) { + logging.error('Failed to initialize extensions:', error); + } + } + + /** + * Load a single extension using webpack module federation + */ + public async loadExtension(manifest: ExtensionManifest): Promise<void> { + const { id, name, frontend } = manifest; + + if (!frontend?.remoteEntry) { + logging.warn(`Extension ${id} has no frontend component, skipping`); + return; + } + + try { + logging.info(`Loading extension: ${name} (${id})`); + + // Construct full URL for remote entry + const remoteEntryUrl = frontend.remoteEntry.startsWith('http') + ? frontend.remoteEntry + : `/static/extensions/${id}/${frontend.remoteEntry}`; + + // Load the remote entry script + await this.loadRemoteEntry(remoteEntryUrl, id); + + // Get the webpack module federation container + const container = (window as any)[id]; + if (!container) { + throw new Error( + `Extension container ${id} not found after loading remote entry`, + ); + } + + // Initialize webpack sharing + // @ts-ignore + await __webpack_init_sharing__('default'); + // @ts-ignore + await container.init(__webpack_share_scopes__.default); + + // Load the main module (typically exposed as './index') + const factory = await container.get('./index'); + const extensionModule = factory(); + + // Create extension context for auto-registration + const context = this.createExtensionContext(id); + + // Set context in the extension's environment so define* functions auto-register + setExtensionContext(context); + + // Create loaded extension record + const loadedExtension: LoadedExtension = { + id, + name, + manifest, + module: extensionModule, + disposables: [], + }; + + // Validate contributions against manifest (security check) + await this.validateContributions(loadedExtension, frontend.contributions); + + this.loadedExtensions.set(id, loadedExtension); + + logging.info(`✅ Extension ${name} loaded and validated successfully`); + } catch (error) { + logging.error(`Failed to load extension ${name}:`, error); + throw error; + } + } + + /** + * Load remote entry script for webpack module federation + */ + private async loadRemoteEntry( + remoteEntry: string, + extensionId: string, + ): Promise<void> { + return new Promise<void>((resolve, reject) => { + // Check if already loaded + if (document.querySelector(`script[src="${remoteEntry}"]`)) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = remoteEntry; + script.type = 'text/javascript'; + script.async = true; + + script.onload = () => { + logging.debug(`Remote entry loaded: ${remoteEntry}`); + resolve(); + }; + + script.onerror = error => { + const message = `Failed to load remote entry: ${remoteEntry}`; + logging.error(message, error); + reject(new Error(message)); + }; + + document.head.appendChild(script); + }); + } + + /** + * Create extension context with registration callbacks + */ + private createExtensionContext(extensionId: string): ExtensionContext { + return { + registerCommand: config => { + const key = `${extensionId}.${config.id}`; + this.contributionRegistry.set(`command:${key}`, config); + logging.debug(`Registered command: ${key}`); + + return () => { + this.contributionRegistry.delete(`command:${key}`); + logging.debug(`Unregistered command: ${key}`); + }; + }, + + registerViewProvider: (id, component) => { + const key = `${extensionId}.${id}`; + this.contributionRegistry.set(`view:${key}`, { id, component }); + logging.debug(`Registered view provider: ${key}`); + + return () => { + this.contributionRegistry.delete(`view:${key}`); + logging.debug(`Unregistered view provider: ${key}`); + }; + }, + + registerEditor: config => { + const key = `${extensionId}.${config.id}`; + this.contributionRegistry.set(`editor:${key}`, config); + logging.debug(`Registered editor: ${key}`); + + return () => { + this.contributionRegistry.delete(`editor:${key}`); + logging.debug(`Unregistered editor: ${key}`); + }; + }, + + registerMenu: config => { + const key = `${extensionId}.${config.id}`; + this.contributionRegistry.set(`menu:${key}`, config); + logging.debug(`Registered menu: ${key}`); + + return () => { + this.contributionRegistry.delete(`menu:${key}`); + logging.debug(`Unregistered menu: ${key}`); + }; + }, + }; + } + + /** + * Validate that runtime contributions match the manifest allowlist + */ + private async validateContributions( + extension: LoadedExtension, + manifestContributions: FrontendContributions, + ): Promise<void> { + const { id } = extension; + + // Small delay to allow define* functions to execute and register + await new Promise(resolve => setTimeout(resolve, 100)); + + // Validate commands + for (const commandDef of manifestContributions.commands) { + const key = `command:${id}.${commandDef.id}`; + if (!this.contributionRegistry.has(key)) { + throw new Error( + `Command ${commandDef.id} declared in manifest but not found in runtime exports`, + ); + } + } + + // Validate views + for (const [, views] of Object.entries(manifestContributions.views)) { + for (const viewDef of views) { + const key = `view:${id}.${viewDef.id}`; + if (!this.contributionRegistry.has(key)) { + throw new Error( + `View ${viewDef.id} declared in manifest but not found in runtime exports`, + ); + } + } + } + + // Validate editors + for (const editorDef of manifestContributions.editors) { + const key = `editor:${id}.${editorDef.id}`; + if (!this.contributionRegistry.has(key)) { + throw new Error( + `Editor ${editorDef.id} declared in manifest but not found in runtime exports`, + ); + } + } + + // Validate menus + for (const [, menus] of Object.entries(manifestContributions.menus)) { + for (const menuDef of menus) { + const key = `menu:${id}.${menuDef.id}`; + if (!this.contributionRegistry.has(key)) { + throw new Error( + `Menu ${menuDef.id} declared in manifest but not found in runtime exports`, + ); + } + } + } + + logging.debug(`✅ All contributions validated for extension ${id}`); + } + + /** + * Get view contributions for a specific location + */ + public getViewContributions( + location: string, + ): Array<{ id: string; name: string; component: React.ComponentType }> { + const views: Array<{ + id: string; + name: string; + component: React.ComponentType; + }> = []; + + for (const [key, contribution] of this.contributionRegistry.entries()) { + if (key.startsWith('view:')) { + const extension = Array.from(this.loadedExtensions.values()).find(ext => + key.startsWith(`view:${ext.id}.`), + ); + + if (extension) { + const viewContributions = + extension.manifest.frontend?.contributions.views[location] || []; + const contributionId = key.replace(/^view:[^.]+\./, ''); + const viewDef = viewContributions.find(v => v.id === contributionId); + + if (viewDef) { + views.push({ + id: contribution.id, + name: viewDef.title, + component: contribution.component, + }); + } + } + } + } + + return views; + } + + /** + * Get command contributions + */ + public getCommandContributions(): Array<any> { + const commands: Array<any> = []; + + for (const [key, contribution] of this.contributionRegistry.entries()) { + if (key.startsWith('command:')) { + commands.push(contribution); + } + } + + return commands; + } + + /** + * Get editor contributions + */ + public getEditorContributions(): Array<any> { + const editors: Array<any> = []; + + for (const [key, contribution] of this.contributionRegistry.entries()) { + if (key.startsWith('editor:')) { + editors.push(contribution); + } + } + + return editors; + } + + /** + * Get menu contributions for a specific location + */ + public getMenuContributions(location: string): Array<any> { + const menus: Array<any> = []; + + for (const [key, contribution] of this.contributionRegistry.entries()) { + if (key.startsWith('menu:')) { + const extension = Array.from(this.loadedExtensions.values()).find(ext => + key.startsWith(`menu:${ext.id}.`), + ); + + if (extension) { + const menuContributions = + extension.manifest.frontend?.contributions.menus[location] || []; + const contributionId = key.replace(/^menu:[^.]+\./, ''); + + if (menuContributions.some(m => m.id === contributionId)) { + menus.push(contribution); + } + } + } + } + + return menus; + } + + /** + * Get loaded extensions + */ + public getLoadedExtensions(): LoadedExtension[] { + return Array.from(this.loadedExtensions.values()); + } + + /** + * Unload an extension + */ + public unloadExtension(id: string): boolean { + const extension = this.loadedExtensions.get(id); + if (!extension) { + return false; + } + + try { + // Dispose all registered contributions + extension.disposables.forEach(dispose => dispose()); + + // Clear from registry + for (const key of this.contributionRegistry.keys()) { + if (key.includes(`${id}.`)) { + this.contributionRegistry.delete(key); + } + } + + this.loadedExtensions.delete(id); + logging.info(`Extension ${extension.name} unloaded successfully`); + return true; + } catch (error) { + logging.error(`Failed to unload extension ${extension.name}:`, error); + return false; + } + } +} + +export default ExtensionLoader; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index b6f03eb5164..aa66ad31884 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -31,7 +31,7 @@ import { import { useSelector } from 'react-redux'; import { RootState } from 'src/views/store'; import { useExtensionsContext } from './ExtensionsContext'; -import ExtensionsManager from './ExtensionsManager'; +import ExtensionLoader from './ExtensionLoader'; declare global { interface Window { @@ -77,7 +77,7 @@ const ExtensionsStartup = () => { // Initialize extensions if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { try { - ExtensionsManager.getInstance().initializeExtensions(); + ExtensionLoader.getInstance().initializeExtensions(); supersetCore.logging.info('Extensions initialized successfully.'); } catch (error) { supersetCore.logging.error('Error setting up extensions:', error); diff --git a/superset/static/service-worker.js b/superset/static/service-worker.js index 43cb14a4894..55f3b1ce6cd 100644 --- a/superset/static/service-worker.js +++ b/superset/static/service-worker.js @@ -1,27 +1 @@ -/** - * 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. - */ - -// Minimal service worker for PWA file handling support -self.addEventListener('install', event => { - event.waitUntil(self.skipWaiting()); -}); - -self.addEventListener('activate', event => { - event.waitUntil(self.clients.claim()); -}); +(()=>{"use strict";let e;var r,t,n,o,a,i,f,u,l,s,d,p,c,v,h,g,y={55725(){self.addEventListener("install",e=>{e.waitUntil(self.skipWaiting())}),self.addEventListener("activate",e=>{e.waitUntil(self.clients.claim())})}},b={};function m(e){var r=b[e];if(void 0!==r)return r.exports;var t=b[e]={id:e,loaded:!1,exports:{}};return y[e].call(t.exports,t,t.exports,m),t.loaded=!0,t.exports}m.m=y,m.c=b,r=[],m.O=(e,t,n,o)=>{if(t){o=o||0;for(var a=r.length;a>0&&r[a-1][2]>o;a--)r[a]=r[a-1];r[a]=[t,n,o]; [...]
