Compare commits

...

38 Commits

Author SHA1 Message Date
Enzo Martellucci
d42da24365 test(extensions): reference chatbots consume the page-context taxonomy
Local testing scaffolding for the chatbot context work — not intended for the
upstream PR (it lives on the test/chatbot-local branch).

- chat (Reference Chatbot): subscribe to entity- and SQL-Lab-context change
  events (onDidChangeChart/Dashboard/Dataset, onDidChangeActiveTab/TabTitle) so
  the panel refreshes after late hydration and on in-surface changes, not only
  on navigation; add chart_list/dashboard_list/dataset_list/query_history/
  saved_queries to the consumer PageType + inference; render the full per-surface
  context vertically.
- chat2 (Alt Chatbot): scaffold real source (previously a prebuilt dist only)
  mirroring chat with its own identity — alt-chatbot id, apacheSuperset_altChatbot
  federation name, "Alt Chatbot" green UI, view-only (no command registration, to
  avoid colliding with Reference's core.chatbot__* ids), open/close via local
  state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:28:10 +02:00
Enzo Martellucci
92224ae270 feat(extensions): refine page-context surface taxonomy for chatbot
Extend the navigation PageType taxonomy so context-consuming extensions can
distinguish browse/list and SQL Lab sub-surfaces, and fix two SQL Lab context
bugs surfaced by it.

Navigation:
- Add chart_list, dashboard_list, dataset_list, query_history and saved_queries
  to PageType, and classify the matching routes in derivePageType (list pages
  and /sqllab/history, /savedqueryview/list). List/sub-pages previously
  collapsed into 'other', and /sqllab/history was mislabeled 'sqllab'.

SQL Lab:
- onDidChangeActiveTab now resolves the active tab via getCurrentTab() instead
  of getTab(action.queryEditor.id). The action payload's editor has no merged
  unsaved dbId yet, so the old parser returned undefined and the event was
  silently swallowed, leaving consumers stuck on the first tab.
- getCurrentTab() now guards on navigation.getPageType() === 'sqllab', so the
  SQL Lab tab no longer leaks onto non-editor surfaces (the slice persists
  after navigating away). Mirrors the explore/dashboard getter guards.

Tests cover the new page types, the switch-away tab event, and the off-surface
getCurrentTab guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:27:27 +02:00
Enzo Martellucci
87956741ff chore: updates chatbot sip 2026-06-05 16:10:01 +02:00
Enzo Martellucci
8efaf38a2f fix(extensions): repair reference-chatbot build so served bundle isn't stale 2026-06-05 11:48:56 +02:00
Enzo Martellucci
979e01e7fb chore: updates SIP 2026-06-04 22:44:04 +02:00
Enzo Martellucci
e2a971ef69 fix: lint 2026-06-04 11:57:27 +02:00
Enzo Martellucci
23f6133983 fix(extensions): enforce CSRF protection on ExtensionsRestApi
FAB's BaseApi defaults csrf_exempt to True, so ExtensionsRestApi — which uses
cookie/session auth (allow_browser_login) and exposes state-changing routes
(settings PUT, extension upload POST, delete) — was silently exempt from CSRF
protection. Superset's own BaseSupersetApi sets csrf_exempt = False for exactly
this reason; mirror that here. Fixes test_csrf_exempt_blueprints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:39:41 +02:00
Enzo Martellucci
be364bb093 test(extensions): register POST/DELETE routes via ENABLE_EXTENSIONS flag
The extension upload/delete endpoints are only mounted when ENABLE_EXTENSIONS
is enabled at app-init time, so the endpoint tests 404'd depending on test
ordering. Parametrize the app fixture on TestPostEndpoint/TestDeleteEndpoint
so the routes are registered deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:36:23 +02:00
Enzo Martellucci
40dcace5d0 test(extensions): register settings route via ENABLE_EXTENSIONS flag
The settings endpoints are only mounted when ENABLE_EXTENSIONS is enabled at
app-init time. The endpoint tests relied on another test enabling the flag
first, so they 404'd in CI's ordering. Parametrize the app fixture on both
endpoint test classes so the route is registered deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:55:34 +02:00
Enzo Martellucci
74f9b72f64 refactor(extensions): route settings persistence through Command + DAO
Addresses review feedback that the settings endpoints bypassed the
Command->DAO pattern used across the codebase.

- superset/daos/extension.py: ExtensionSettingsDAO / ExtensionEnabledDAO
  (BaseDAO subclasses). Upserts use a portable check-then-write path so all
  metadata backends work without dialect-specific SQL or NotImplementedError.
- superset/commands/extension/settings/: Get/Update commands. UpdateCommand
  validates the payload (rejects non-string/oversized active_chatbot_id and
  oversized/non-string enabled keys) before any write, and wraps writes in
  @transaction so DB errors surface as ExtensionSettingsUpdateFailedError.
- api.py now constructs and runs the commands; pre-validates so malformed
  input returns 400 (the FAB @safe wrapper would otherwise yield 500).
- Remove superset/extensions/settings.py; tests rewritten against the
  Command + DAO layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:23:00 +02:00
Enzo Martellucci
579c8d8377 fix(extensions): validate settings payload and harden chatbot resolver
Addresses remaining PR review comments:

- Reject invalid active_chatbot_id types (e.g. ints) and oversized ids with
  a 400 instead of silently coercing to null / failing at the DB layer.
- Reject oversized / non-string enabled-map keys with a 400 before the DB
  enforces the column length.
- Share the id column length via EXTENSION_ID_MAX_LENGTH so validation and
  the schema cannot drift.
- Chatbot resolver now falls through to the next candidate when a view id
  fails to resolve, instead of returning undefined on the first miss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:03:09 +02:00
Enzo Martellucci
369e4adc76 feat(extensions): restore dataset context namespace with a real producer
Re-introduce the `dataset` namespace end-to-end so chatbot extensions can read
which dataset the user is viewing/editing. It was previously removed because the
SDK exported a typed contract with no runtime producer (calls threw); this adds
the producer so the contract is backed.

- SDK: DatasetContext + getCurrentDataset/onDidChangeDataset, barrel export, and
  the "./dataset" package.json subpath.
- Host: src/core/dataset with setCurrentDataset (producer) + getter + change
  event; exported from src/core and registered on window.superset.
- Producer: the dataset edit page (EditPage) fetches the dataset and publishes
  { datasetId, datasetName, schema, catalog, databaseName, isVirtual } via
  setCurrentDataset on load, clearing it on unmount. Fields map per the SIP
  contract; databaseName/schema/catalog are nullable.
- Use a stable module-level error handler for useSingleViewResource so
  fetchResource keeps a fixed identity — an inline handler made the fetch effect
  re-fire every render (Maximum update depth exceeded).
- Add the dataset-entity fetch mock to EditDataset.test, and add useRouter to
  ExtensionsStartup.test renders (it uses useLocation; tests must wrap a Router).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:36:05 +02:00
Enzo Martellucci
76466e3845 adds tickets.md 2026-06-02 16:55:59 +02:00
Enzo Martellucci
0249b8c1b3 fix(extensions): track async registrations via activate(context)
Port the activate(context) lifecycle fix (PR #40441 review feedback) to the
integration branch, and align the chatbot SIP.

Previously deactivateExtension only disposed registrations captured by the
synchronous window.superset registrar-wrapping during module evaluation, so
contributions registered from an async continuation leaked on deactivation.
Extensions now export activate(context) and push each Disposable onto
context.subscriptions, whose lifetime is bound to the context object rather
than a synchronous window — async and synchronous registrations are tracked
alike. The registrar-wrapping is retained as a synchronous-only fallback for
legacy side-effect extensions.

- Add ExtensionContext / ExtensionModule to @apache-superset/core
- loadModule awaits module.activate(context), returns context.subscriptions
- deactivateExtension disposes context.subscriptions
- Fix stale subscribeToLocation comment -> subscribeToRegistry
- Tests: legacy synchronous disposal + async-in-activate tracking
- SIP: describe the activate(context) model; resolve the async-registration
  open item (async dispose-await remains pending)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:07:43 +02:00
Enzo Martellucci
a583f1859c Merge branch 'master' into test/chatbot-local 2026-06-02 12:53:52 +02:00
Enzo Martellucci
6d174fa71f docs(extensions): fix logic errors and code-doc drift in chatbot SIP
- Fix broken getCurrentDashboard example (syntax error, raw nativeFilters/
  slices exposure contradicting the "normalized only" point, wrong field
  names, missing ChartSummary.isVisible)
- Correct page-type vocabulary: `chart` -> `explore` to match PageType
- Stop claiming `icon` is "proposed" — it already exists on the View
  descriptor and the registration example already passes it
- Remove phantom "UI-control state" and navigation "focused entity"
  promises that no contract actually exposes
- Distinguish implemented namespaces (dashboard/explore/navigation) from
  specified-but-not-implemented ones (dataset, DashboardContext.charts)
- Fix malformed `explore` status marker; align getViews descriptor field
  list to include `icon`

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:51:08 +02:00
Enzo Martellucci
15ad31effd updates the chatbot sip 2026-06-01 23:45:04 +02:00
Enzo Martellucci
32c42076dd adds the admin extension list details 2026-06-01 15:08:53 +02:00
Enzo Martellucci
7f6f805ffa fix(extensions): sync second-round review fixes to chatbot-local branch
- Validate manifest.id segments in POST endpoint before building dest_file path
- Add hostile manifest.id test (../../tmp/evil → 400)
- Add sqlite-backed round-trip tests for settings.py upsert logic
- Add HTTP tests for GET/PUT /api/v1/extensions/settings endpoints
- Wrap handleDelete in useCallback; already in columns useMemo deps
- Fix MySQL comment drift in _upsert_settings_row (read-then-update, not merge)
- Add intentional no-admin-gate comment to get_settings endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:21:30 +02:00
Enzo Martellucci
df6e0095dc fix(extensions): sync PR review fixes to test/chatbot-local
- ExtensionsList.tsx: unique Tooltip ids per row; onKeyDown Enter/Space for
  star and delete role=button spans; data-test attrs; remove stale loading dep
- ExtensionsList.test.tsx: 10 unit tests (import validation, delete confirm,
  star toggle, keyboard a11y, file upload)
- api.py: path-traversal validation, upload size limit, LOCAL_EXTENSIONS 409
- test_api.py: 19 Python unit tests for POST and DELETE endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:05:43 +02:00
Enzo Martellucci
c4b9f7b6e5 fix(extensions): fully remove dataset namespace — SDK contract and orphaned host impl
The host removed dataset from window.superset but the SDK still exported
the typed contract. An extension importing dataset from @apache-superset/core
would get a fully typed namespace whose runtime calls throw at access time,
which is worse than not shipping it.

Removes:
- packages/superset-core/src/dataset/index.ts (SDK type declarations)
- export * as dataset from './dataset' in superset-core/src/index.ts
- "./dataset" subpath from superset-core/package.json exports
- src/core/dataset/index.ts (orphaned host implementation)

The namespace will be re-introduced once a producer (DatasetCreation or
equivalent) calls setCurrentDataset to back the contract at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:29:41 +02:00
Enzo Martellucci
23d4574caf fix(extensions): address context-sharing PR review — blockers, quality, and tests
- Add CREATE_NEW_SLICE and SLICE_UPDATED to exploreChangePredicate so
  onDidChangeChart fires when a chart is saved for the first time
- Remove dataset namespace: no producer exists yet; ships it once a
  caller is wired in DatasetCreation or equivalent
- Remove ...supersetCore spread from window.superset assignment so
  un-contracted symbols from @apache-superset/core are not leaked onto
  the global object; list namespaces explicitly instead
- Add defensive array copy for filter values in buildDashboardContext
  so extension mutations cannot affect Redux state
- Lazy-initialize currentPageType in navigation to avoid module-load
  window.location access (throws in non-browser test environments)
- Fix /sqllab exact-match missing from derivePageType
- Add unit tests: navigation (7), explore (9), dashboard (11) — 27 tests
  covering page-type gating, dispose semantics, predicate coverage, and
  defensive copy invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:16:37 +02:00
Enzo Martellucci
da4d09a006 fix(extensions): complete model move — remove from core.py, fix import, ensure ORM registration
- Create superset/extensions/models.py with ExtensionSettings and ExtensionEnabled.
- Remove both classes from superset/models/core.py.
- Update superset/extensions/settings.py import to the new path.
- ORM registration is guaranteed by the existing import chain:
  api.py → settings.py → extensions/models.py — no additional import needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:51:38 +02:00
Enzo Martellucci
1645a9652f fix(extensions): address PR review — rollback, import cleanup, tests, guard
- ExtensionsList: snapshot previous settings before optimistic write;
  rollback setSettings + notifyExtensionSettingsChanged in .catch() so a
  failed PUT leaves the UI consistent with server state. Drop
  setSettings(json.result) from .then() — optimistic write is source of
  truth. Switch onClick → onChange. Consolidate Switch/Select/etc into
  single @superset-ui/core/components import.
- ChatbotMount: revert undefined loading gate (immediate render, fall
  back on fetch error); guard json.result with ?? fallback; merge React
  imports; promote ChatbotRenderer comment to JSDoc.
- Tests: add getActiveChatbot coverage for admin-pin, stale-pin fallback,
  enabled-filter exclusion, all-disabled. Add ChatbotMount test for
  provider function throwing synchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:36:05 +02:00
Enzo Martellucci
22d9332794 fix(extensions): push settings payload through pub/sub to eliminate re-fetch delay on toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:02:36 +02:00
Enzo Martellucci
504826bb24 refactor(extensions): replace per-location pub/sub with registry-wide version counter + useSyncExternalStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:52:57 +02:00
Enzo Martellucci
4e8145f14b fix(extensions): match /sqllab path without trailing slash in derivePageType
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:26:45 +02:00
Enzo Martellucci
a74684b062 fix(extensions): port CodeAnt fixes — page-type guards, deactivation cleanup, settings race, feature flag gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:10:16 +02:00
Enzo Martellucci
96f2fb3659 fix(extensions): defer chatbot render until settings load; isolate provider errors
- Don't call getActiveChatbot before settings arrive — start with null
  so no chatbot is rendered until the admin selection is known, avoiding
  a flash of the wrong chatbot when multiple are registered
- On settings fetch failure fall back to first-to-register instead of
  rendering nothing forever
- Wrap provider call in a ChatbotRenderer child component so
  ErrorBoundary actually catches provider-level throws (calling the
  provider inline during render means errors bubble before the boundary
  can mount)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:57:05 +02:00
Enzo Martellucci
e3efa0ae71 feat(extensions): consolidate actions column and live settings sync
- Single Actions column: switch (enable/disable), star (default chatbot,
  chatbot-type only, toggleable), trash (hidden for LOCAL_EXTENSIONS)
- Add `deletable` field to build_extension_data so the frontend knows
  which extensions can be removed via the UI
- Add `publisher` field to Extension type for correct delete URL
- Add notifyExtensionSettingsChanged / subscribeToExtensionSettings
  pub/sub in core/extensions so ChatbotMount re-fetches on any settings
  change without a page reload
- Wire ChatbotMount to subscribe to settings changes via fetchSettings
  callback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:51:05 +02:00
Enzo Martellucci
97ba65b0cd feat(extensions): port import/delete UI and backend endpoints to test branch
- POST /api/v1/extensions/ — admin upload of .supx bundles
- DELETE /api/v1/extensions/<publisher>/<name> — admin removal
- Import button in ExtensionsList SubMenu (file picker, .supx only)
- Per-row delete action with confirmation dialog
- Keeps existing Select (default chatbot) and Switch (enabled) UI
- Add publisher to build_extension_data response
- Add subscribeToLocation / getRegisteredViewIds / getViewProvider to
  src/core/views/index.ts so ExtensionsList can detect chatbot extensions
- Fix scripts/oxlint.sh set -e / [ -n "" ] false-positive exit
- Fix settings.py MySQL fallback: use read-then-update instead of
  try/except/rollback to satisfy the custom consider-using-transaction rule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:30:32 +02:00
Enzo Martellucci
d7592913ea feat(extensions): reference chatbot extension — docs and example
Ships the full extensions/chat reference implementation that exercises
the chatbot extension platform end-to-end: activation lifecycle and
master disposable (teardown contract), React error boundary (fault
isolation), mock streaming with AbortController cancellation, commands
registration, and the pageContext helper that composes host namespaces.

Local branch only — not intended for upstream merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:56:28 +02:00
Enzo Martellucci
8f03c5a1ea feat(extensions): context sharing namespaces (navigation, explore, dashboard, dataset)
Adds four stable namespaces to @apache-superset/core that give extensions
host-managed access to page context without coupling to Redux internals:

- navigation: getPageType(), onDidChangePage (routing signal only)
- explore: getCurrentChart(), onDidChangeChart (ChartContext from Redux)
- dashboard: getCurrentDashboard(), onDidChangeDashboard (DashboardContext
  with active native filter values from Redux)
- dataset: getCurrentDataset(), onDidChangeDataset (push model)

All four are wired into window.superset via ExtensionsStartup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:49:22 +02:00
Enzo Martellucci
8b52fff57b feat(extensions): backend settings persistence and admin-only permissions
Adds ExtensionSettings and ExtensionEnabled models with migration.
GET /api/v1/extensions/settings is public; PUT is restricted to Admin
role via security_manager.is_admin(). Uses dialect-aware ON CONFLICT DO
UPDATE upserts and @transaction() for safe concurrent writes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:49:15 +02:00
Enzo Martellucci
197e14de1b feat(extensions): admin configuration UI for extensions
Adds enable/disable toggles per extension and an active-chatbot selector
(shown when multiple chatbot extensions are registered) to the Extensions
list view. Settings are persisted via PUT /api/v1/extensions/settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:49:07 +02:00
Enzo Martellucci
7c9efd529b feat(extensions): eager-load extensions at app-shell startup
ExtensionsStartup initializes extensions behind the EnableExtensions
feature flag immediately after the user session is confirmed, wires
window.superset, and isolates unhandled rejections from extension code.
ChatbotMount is mounted at the app root via App.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:49:01 +02:00
Enzo Martellucci
f8e7ee9dc2 feat(extensions): define the chatbot entry point in the frontend API
Adds getActiveChatbot() singleton resolver (first-to-register + admin
active_chatbot_id + enabled-flag enforcement), subscribeToLocation() for
reactive re-resolution, and ChatbotMount — the fixed bottom-right slot
that persists across routes and renders the active chatbot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:48:53 +02:00
Enzo Martellucci
c77c9b29f9 feat(extensions): define the superset.chatbot contribution point
Adds the `superset.chatbot` app-level location to ViewContributions and
exports ChatbotView from the contributions namespace. Introduces
src/views/contributions.ts as the host-side CHATBOT_LOCATION constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:32:42 +02:00
97 changed files with 8727 additions and 99 deletions

895
CHATBOT_SIP.md Normal file
View File

@@ -0,0 +1,895 @@
Chatbot extensions
Author: Enzo Martellucci
Team: Preset
Status: Draft | Under Review | Completed
Day: May, 2026
1. Introduction
This SIP proposes a new extension point that enables third-party chatbot integrations to be embedded directly into the Superset user interface through the existing extension framework.
The goal is to provide a stable, supported mechanism for chatbot providers to integrate with Superset without requiring direct access to internal application state, Redux stores, or implementation-specific frontend modules. Chatbot extensions should interact with Superset through the same extension-oriented principles already established for other extension surfaces, such as SQL Lab.
The proposal focuses on three core concerns:
• Defining how chatbot extensions are registered and rendered.
• Defining how chatbot extensions receive contextual information about the currently active application surface.
• Defining how administrators manage chatbot availability and select the active chatbot when multiple chatbot extensions are installed.
This SIP intentionally does not prescribe any specific chatbot implementation, user experience, LLM provider, or backend architecture.
1.1 Motivation
AI-powered assistants are becoming a common way for users to interact with analytical applications. Superset should provide a standardized extension mechanism that allows community-built chatbot integrations to participate in the platform without depending on internal frontend implementation details.
Today, chatbot integrations must either be embedded through custom application modifications or rely on unsupported access to internal application state. Both approaches create maintenance challenges and make integrations fragile when frontend architecture evolves.
This SIP introduces a stable extension contract that:
• Enables chatbot integrations to be distributed as standard Superset extensions.
• Preserves separation between host and extension responsibilities.
• Allows chatbot implementations to access contextual information about the current page and entity being viewed.
• Keeps authorization and permission enforcement aligned with existing Superset APIs.
• Remains compatible with future frontend architecture changes.
1.2 Goals
The goals of this SIP are:
Introduce a dedicated chatbot extension point within the Superset application shell.
Provide chatbot extensions with host-managed, permission-aligned page context.
Establish stable extension-facing APIs for dashboard, explore, dataset, and navigation context.
Support deployment-wide administration of chatbot availability and selection.
Maintain isolation between chatbot implementations and Superset internals.
Preserve compatibility with future extension capabilities and AI-related initiatives.
1.3 Out of Scope
The following capabilities are explicitly out of scope for this SIP.
Client Actions and Agentic UI Manipulation
This SIP defines how chatbot extensions are mounted and how they receive context from the host application.
It does not define how a chatbot performs actions within the user interface, such as:
• Modifying chart configuration.
• Updating dashboard layouts.
• Editing SQL queries.
• Triggering frontend workflows.
These capabilities are deferred to the proposed Client Actions SIP.
Chatbot User Experience
The chatbot user interface remains entirely owned by the extension.
This SIP does not prescribe:
• Visual design.
• Conversation experience.
• Streaming behavior.
• Message persistence.
• Prompting strategy.
• Accessibility implementation details.
• Branding or styling.
LLM and Backend Infrastructure
The following concerns remain extension-specific:
• Model providers.
• MCP implementations.
• Agent frameworks.
• Tool execution systems.
• Prompt orchestration.
• Backend services.
Superset acts only as the host application and context provider.
2. Requirements
2.1 Functional Requirements
Registration and Rendering
The platform must allow extensions to register chatbot providers through the standard extension system.
The host must:
• Support registration of chatbot extensions.
• Render a chatbot UI contributed by an extension.
• Maintain a single active chatbot instance at any given time.
• Make the chatbot available across supported application surfaces.
• Support fully custom chatbot user interfaces.
Context Sharing
The platform must provide chatbot extensions with contextual information about the user's current application state.
At minimum, the host must expose:
• Current page type (`home`, `dashboard`, `explore`, `sqllab`, `dataset`, `other`).
• Dashboard context.
• Explore/chart context.
• Dataset identity context.
• SQL Lab context.
• Navigation events.
The chatbot must be notified of relevant context changes without polling.
Examples include:
• Route changes.
• Dashboard changes.
• Chart changes.
• Dataset changes.
• Title changes.
• Filter changes.
Host-Owned Context
Context exposed to extensions must be computed by the host application.
Extensions must not be required to:
• Read Redux state.
• Access internal application modules.
• Depend on component-level implementation details.
• Reconstruct semantic context from frontend internals.
Instead, extensions consume stable namespace APIs provided by the host.
Conversation State
The conversation state remains entirely owned by the chatbot extension.
This includes:
• Message history.
• Tool execution state.
• Streaming buffers.
• Conversation persistence.
• Session management.
The host is responsible only for exposing contextual information.
2.2 Non-Functional Requirements
Security and Authorization
Context shared with chatbot extensions must remain aligned with Superset's existing authorization model.
The host must not expose:
• Entities the current user cannot access.
• Metadata outside the user's permission scope.
• Datasource-derived information unavailable through existing APIs.
Authorization remains enforced by backend APIs. The extension-facing APIs defined by this SIP operate on data that has already been scoped to the current user.
Stable Extension Contracts
Extension-facing APIs must remain independent of frontend implementation details.
Extensions should rely on documented namespace contracts rather than:
• Redux slices.
• Internal selectors.
• Component state.
• Routing implementation details.
This allows frontend architecture to evolve without breaking extensions.
Performance
The architecture must minimize impact on existing application performance.
In particular:
• Context APIs must avoid unnecessary application re-renders.
• Context change notifications must not rely on polling.
• Chatbot integrations should not introduce additional work for unrelated surfaces.
Fault Isolation
Failures within chatbot extensions must not affect the stability of the host application.
Errors originating from third-party chatbot implementations should be isolated to the chatbot mount boundary.
Extensibility
The architecture should support future:
• Application surfaces.
• AI-related capabilities.
• Extension APIs.
• Context providers.
without requiring redesign of the chatbot extension model.
Vendor Neutrality
The architecture must remain independent of any specific:
• LLM provider.
• AI platform.
• Agent framework.
• Backend implementation.
3. Administration
3.1 Overview
Administrators can manage chatbot availability and select the active chatbot when multiple chatbot extensions are installed.
Administration is exposed through the existing Extensions management interface.
For chatbot extensions, administrators can:
• Enable or disable individual chatbot extensions.
• Select the default chatbot when multiple chatbot providers are available.
Only one chatbot may be active at a time.
3.2 Default Chatbot Selection
Extensions that contribute a chatbot view participate in a deployment-wide chatbot selection process.
The host discovers available chatbot candidates from the chatbot contribution location and allows administrators to designate a single active chatbot.
When multiple chatbot extensions are installed:
Administrators select the preferred chatbot.
The host resolves the active chatbot using the configured selection.
Only the selected chatbot is rendered.
Changes are applied dynamically without requiring a page reload.
3.3 Scope of Administration
The administration model introduced by this SIP is deployment-wide.
Administrative settings answer the question:
"Which chatbot integrations are available within this Superset deployment?"
They do not answer:
"Which chatbot integrations does a specific user prefer to use?"
This distinction is intentional.
Deployment administrators determine which integrations are available across the environment, while user-specific preferences remain a separate concern.
3.4 Future User Preferences
Per-user chatbot preferences are considered an important future capability but are intentionally out of scope for this SIP.
This proposal does not introduce user-scoped extension availability.
Instead, future user preferences should be layered on top of deployment availability using the following model:
Effective Availability = Deployment Availability AND User Preference
The recommended persistence layer for future user preferences is the Extension Storage API, which provides user-scoped extension storage and aligns with the architecture established by SIP-127 (User Preferences).
This separation preserves a clear distinction between:
• Deployment configuration.
• User customization.
and avoids introducing multiple ownership models for extension availability.
Consequently, this SIP focuses exclusively on deployment-wide administration and active chatbot selection. 4. Proposed Extension Point
4.1 Overview
This SIP introduces a single extension point that allows chatbot providers to integrate directly into the Superset application shell.
Extension Point
Contribution Location
Registration API
Cardinality
Chatbot Bubble
superset.chatbot
views.registerView()
Singleton
The chatbot contribution point is application-wide and persists across supported Superset surfaces, including dashboards, Explore, SQL Lab, and dataset-related pages.
Unlike most contribution locations, which allow multiple contributions to be rendered simultaneously, the chatbot location is intentionally exclusive and renders a single active provider.
4.2 Chatbot Contribution Location
Contribution Area
The contribution location introduced by this SIP is:
superset.chatbot
The host provides a fixed mount point within the application shell and renders the active chatbot provider at that location.
The mount point persists across route changes, allowing chatbot conversations and UI state to remain available while users navigate between application surfaces.
The chatbot extension contributes a single React component representing the entire chatbot experience.
Manifest Support
The current contribution manifest schema is focused on SQL Lab contribution locations and does not provide an application-shell-level contribution scope.
To support chatbot integrations, the manifest schema must be extended with an application-level contribution scope capable of declaring:
{
"views": {
"app": [
{
"location": "superset.chatbot"
}
]
}
}
This is a schema-level change and requires updates to both:
• Manifest validation.
• Runtime registration infrastructure.
The runtime registration API alone is not sufficient because chatbot contributions must also be discoverable through extension manifests.
4.3 Singleton Rendering Model
The chatbot location is intentionally exclusive.
Only one chatbot may be active at a time.
This differs from other contribution locations that allow multiple views to be rendered simultaneously.
Motivation
Chatbot interactions are inherently conversational and user-focused.
Rendering multiple chatbot providers simultaneously would:
• Create competing user experiences.
• Introduce ambiguity regarding which chatbot should respond.
• Increase UI complexity.
• Reduce discoverability.
For these reasons, chatbot rendering is treated as a deployment-level selection rather than a multi-provider composition model.
Resolution Rules
The host applies the following behavior:
Installed Chatbots
Behavior
None
No chatbot is rendered
One
The chatbot is rendered automatically
Multiple
The administrator-selected chatbot is rendered
The singleton policy is implemented entirely by the host.
Extensions continue to register normally through the existing view registry.
4.4 Provider Isolation
A key architectural principle of this SIP is that extensions may discover registrations but may not invoke another extension's rendering logic.
Public View Discovery
The existing registry exposes:
getViews(location);
This API returns metadata describing registered views:
interface View {
id: string;
name: string;
description?: string;
icon?: string;
}
The returned descriptors are intentionally passive metadata.
They allow extensions and host components to:
• Discover available contributions.
• Display contribution information.
• Populate administration interfaces.
They do not allow rendering.
Why Providers Are Not Exposed
The view provider is executable rendering logic.
If providers were exposed through the public registry:
• Extensions could render another extension's UI.
• Extensions could bypass host lifecycle management.
• Extensions could circumvent fault-isolation boundaries.
• Rendering ownership would become ambiguous.
This would violate the separation between extension discovery and extension execution.
For this reason:
"Extensions may discover registered views, but only the host may render registered views."
Host-Managed Resolution
The host uses internal APIs to resolve the active chatbot provider.
These APIs are not exposed through the public extension surface.
Conceptually:
const provider = getViewProvider("superset.chatbot", selectedId);
The active chatbot is determined through a host-managed resolution policy:
const chatbot = getActiveChatbot(adminSelectedId, enabledMap);
This policy considers:
• Enabled state.
• Administrative selection.
• Runtime settings.
• Registration state.
before rendering any provider.
As a result, chatbot selection is implemented as a host-side rendering policy rather than a new registration primitive.
4.5 Chatbot Lifecycle
Host Responsibilities
The host is responsible for:
• Providing the chatbot mount point.
• Resolving the active chatbot provider.
• Loading chatbot extensions.
• Managing chatbot lifecycle integration.
• Handling activation and deactivation.
• Maintaining fault isolation boundaries.
• Preserving chatbot availability across route changes.
• Providing context APIs defined by this SIP.
The host also provides fixed positioning and layering behavior to ensure chatbot visibility remains consistent throughout the application.
Fault Isolation
Chatbot providers execute within a host-managed boundary.
Failures originating from a chatbot extension must not affect the rest of the application.
Examples include:
• Module Federation loading failures.
• Runtime exceptions.
• Provider initialization errors.
If a chatbot fails to load, the host logs the failure, surfaces an appropriate notification, and continues operating normally.
The application shell remains functional even when the chatbot provider is unavailable.
4.6 Extension Responsibilities
The registered chatbot component owns the complete chatbot experience.
The extension is responsible for:
User Interface
• Collapsed bubble UI.
• Expanded panel UI.
• Branding.
• Icons and badges.
• Layout.
• Responsiveness.
Interaction Model
• Open and close behavior.
• Keyboard shortcuts.
• Focus management.
• Accessibility behavior.
• Conversation navigation.
Conversation Runtime
• Message history.
• Streaming state.
• Tool execution.
• Persistence.
• Session management.
Backend Integration
• LLM communication.
• MCP integration.
• Agent orchestration.
• Tool invocation.
The host does not manage any chatbot-specific runtime state.
4.7 Registration Example
Chatbot extensions register a single provider through the existing view registration API.
import { views, type ExtensionContext } from '@apache-superset/core';
import { ChatbotApp } from './ChatbotApp';
export function activate(context: ExtensionContext) {
const disposable = views.registerView(
{
id: 'acme.chatbot',
name: 'Acme Chatbot',
icon: 'Bubble',
},
'superset.chatbot',
() => <ChatbotApp />,
);
context.subscriptions.push(disposable);
}
The registration process remains consistent with existing extension contribution patterns.
The only difference is that the host applies singleton resolution before selecting the provider to render.
4.8 Chatbot Descriptor Metadata
Chatbot registrations may include an optional icon descriptor.
{
id: 'acme.chatbot',
name: 'Acme Chatbot',
icon: 'Bubble',
}
This metadata is used by:
• Extension administration interfaces.
• Chatbot selection interfaces.
• Extension discovery surfaces.
Design Decision
The icon descriptor is treated as static registration metadata.
Runtime UI state such as:
• Notification indicators.
• Unread counts.
• Loading states.
• Thinking indicators.
belongs to the chatbot component itself rather than the registration descriptor.
This keeps the registry simple while allowing chatbot implementations complete control over their user experience.
If future requirements emerge for host-visible dynamic icon updates, that capability can be introduced independently without expanding the registration model defined by this SIP. 5. Context and Namespace Model
5.1 Overview
Chatbot extensions require access to contextual information about the user's current activity within Superset. This SIP introduces a namespace-based context model that allows extensions to consume stable, host-managed APIs rather than depending on internal frontend implementation details.
The host exposes context through a set of surface-specific namespaces. Each namespace owns the context for a particular application surface and provides:
• Synchronous state getters.
• Event-based change notifications.
• Stable extension-facing contracts.
• Context aligned with the current user's authorized application view.
Extensions consume these namespaces and compose them into higher-level context models tailored to their own use cases.
5.2 Design Principles
The namespace model is guided by the following principles.
Stable Extension Contracts
Extensions must depend on documented APIs rather than frontend implementation details.
In particular, extensions must not depend on:
• Redux slices.
• Store shape.
• Selectors.
• Component-local state.
• Routing implementation details.
This allows Superset to evolve its frontend architecture without breaking extension integrations.
Host-Owned Context Normalization
The host is responsible for transforming application state into semantic extension-facing contracts.
Extensions consume normalized context rather than deriving it from raw frontend state.
Backend-Authorized Context
Authorization remains a backend responsibility.
Namespaces expose context that has already been scoped by backend APIs according to the current user's permissions.
Namespaces do not implement authorization logic themselves and should not be considered security boundaries.
Event-Driven Updates
Context changes are propagated through events rather than polling.
Extensions can subscribe to context updates and react immediately when relevant application state changes.
5.3 Available Namespaces
The following namespaces are available to chatbot extensions.
Namespace
Status
Purpose
sqlLab
Existing
SQL Lab context and events
authentication
Existing
Current user and session context
commands
Existing
Host actions and commands
dashboard
New
Dashboard context
explore
New
Explore/chart context
dataset
New
Dataset identity context
navigation
New
Routing and page context
The new namespaces introduced by this SIP follow the same high-level contract pattern established by the existing sqlLab namespace.
5.4 Namespace API Shape
Each namespace follows a common structure:
const current = namespace.getCurrent();
const disposable = namespace.onDidChange((next) => {
// react to updates
});
The exact contracts differ by surface, but every namespace provides:
• One or more synchronous getters.
• Event-based change notifications.
• Stable semantic contracts.
This pattern allows extensions to remain synchronized with application state without polling.
5.5 Dashboard Namespace
The dashboard namespace provides contextual information about the currently active dashboard.
API
dashboard.getCurrentDashboard();
Contract
interface DashboardContext {
dashboardId: number;
title: string;
filters: FilterValue[];
charts: ChartSummary[];
}
interface ChartSummary {
chartId: number;
chartName: string;
vizType: string;
datasourceId: number | null;
datasourceName: string | null;
isVisible: boolean;}
The context includes:
• Dashboard identity.
• Active filter state.
• Dashboard charts.
• Per-chart visibility information.
Returning all charts while exposing visibility allows chatbot implementations to answer both:
• "Which charts are currently visible?"
• "Find the chart named Revenue by Region."
without requiring additional lookups.
Normalization Requirements
The namespace must expose semantic dashboard context rather than raw application state.
For example:
dashboard.getCurrentDashboard();
returns a normalized contract rather than Redux slices or internal entities.
This abstraction layer preserves compatibility as frontend implementation details evolve.
Page-Type Guarding
The getter returns undefined when the current page is not a dashboard.
Conceptually:
if (navigation.getPageType() !== "dashboard") {
return undefined;
}
This prevents stale dashboard state from leaking across application surfaces.
5.6 Explore Namespace
The explore namespace provides context for the currently active Explore session.
API
explore.getCurrentChart();
Contract
interface ChartContext {
chartId: number | null;
chartName: string | null;
datasourceId: number | null;
datasourceName: string | null;
vizType: string;
}
The namespace exposes:
• Chart identity. `chartId` and `chartName` are null for a new, unsaved chart that has not yet been persisted.
• Saved chart metadata (name, datasource, viz type)
• Current Explore context: `vizType` reflects the type currently selected in the editor, so the value tracks the live session rather than only the last saved state.
The contract is intentionally focused on chart-specific information relevant to chatbot integrations.
Reflecting the live editing session — rather than reconstructing chart state from
the route alone — is the primary reason this SIP exposes frontend context
directly (see §6.2, Option C).
Page-Type Guarding
The getter returns undefined when the current page is not an Explore surface.
Conceptually:
if (navigation.getPageType() !== "explore") {
return undefined;
}
This ensures the namespace reflects only active Explore context.
5.7 Dataset Namespace
The dataset namespace exposes the dataset currently being viewed or edited.
API
dataset.getCurrentDataset();
Contract
interface DatasetContext {
datasetId: number;
datasetName: string;
schema: string | null;
catalog: string | null;
databaseName: string | null;
isVirtual: boolean;}
This contract is intentionally identity-focused.
It answers:
• Which dataset is currently in focus?
• Is the dataset virtual or physical?
• Which database and schema does it belong to?
It does not expose:
• Column definitions.
• Lineage information.
• Dataset dependencies.
Those concerns are expected to be resolved by backend services using the dataset identifier.
Producer-Backed Context
Unlike dashboard and explore namespaces, dataset pages do not currently expose a shared source of truth suitable for namespace consumption.
For this reason, dataset context is published by dataset pages through a host-managed producer mechanism.
Dataset pages publish the active dataset as it loads, and:
dataset.getCurrentDataset();
returns the most recently published value.
Until dataset information has been published, the getter returns:
undefined;
This design keeps the public contract stable without requiring the introduction of a dedicated Redux slice.
Example Use Cases
The dataset namespace enables chatbot workflows such as:
• Explain this dataset.
• Summarize this dataset's purpose.
• Show lineage for this dataset.
• Which charts depend on this dataset?
The namespace provides the identity required to perform those lookups while avoiding duplication of backend metadata.
5.8 Navigation Namespace
The navigation namespace provides routing-related context.
API
navigation.getPageType();
Events
navigation.onDidChangePage(...)
Contract
type PageType =
| "home"
| "dashboard"
| "explore"
| "sqllab"
| "dataset"
| "other";
The namespace answers a single question:
"Which application surface is currently active?"
It intentionally does not expose entity-specific information.
Entity context remains owned by the corresponding surface namespace.
Examples:
dashboard.getCurrentDashboard();
explore.getCurrentChart();
dataset.getCurrentDataset();
This separation preserves clear ownership boundaries and prevents duplication across namespaces.
5.9 Context Composition
This SIP intentionally does not introduce a host-owned aggregate context object.
Instead, extensions compose the context they require from individual namespaces.
For example:
const pageContext = {
pageType: navigation.getPageType(),
dashboard: dashboard.getCurrentDashboard(),
chart: explore.getCurrentChart(),
dataset: dataset.getCurrentDataset(),
sqlLab: sqlLab.getCurrentTab(),
};
The extension assembles a higher-level context tailored to its own requirements.
The host remains responsible for:
• Context ownership.
• Context normalization.
• Authorization alignment.
The extension remains responsible for:
• Context composition.
• Prompt construction.
• Application-specific interpretation.
This separation avoids introducing a centralized context abstraction while allowing new surfaces to be added incrementally over time.
5.10 Compatibility and Evolution
Namespace contracts are part of the public Superset extension API surface.
Breaking changes require standard compatibility and deprecation processes.
Extensions should depend only on documented namespace contracts and must not rely on implementation details behind those contracts.
As new application surfaces become extension-aware, additional namespaces may be introduced without affecting existing integrations.
This additive model allows the extension ecosystem to evolve while preserving backward compatibility.
6. Design Decisions
This section consolidates the key architectural decisions made by this SIP and summarizes the alternatives that were evaluated.
The goal is to capture the rationale behind the extension model so that future contributors can understand not only what was selected, but why alternative approaches were rejected.
6.1 Decision Summary
Decision
Topic
Selected Approach
D1
Page Context Model
Extension-composed context from host-provided namespaces
D2
Chatbot Resolution
Host-managed singleton resolution
D3
Descriptor Metadata
Static icon metadata
D4
Administration Scope
Deployment-wide administration
D5
Per-Page Visibility
Deferred - open question, see §8
D6
Generalized Floating Slots
Deferred - open question, see §8
6.2 D1 — Page Context Model
A central design question is how chatbot extensions obtain contextual information about the currently active application surface.
Three approaches were considered.
Option A — Host-Owned Aggregate Context
The host exposes a single API:
context.getPageContext();
which returns a fully assembled context object containing dashboard, chart, dataset, navigation, and SQL Lab information.
Rejected Because
• The host becomes responsible for understanding every application surface.
• The aggregate contract grows whenever a new surface is introduced.
• Changes in any surface can trigger unnecessary recomputation.
• The host becomes coupled to a single canonical context model.
• Ownership boundaries become unclear over time.
Option B — Surface Namespaces Composed by Extensions (Selected)
The host exposes independent namespaces:
• dashboard
• explore
• dataset
• navigation
• sqlLab
Extensions compose these primitives into their own application-specific context.
Advantages
• Clear ownership boundaries.
• Independent evolution of namespaces.
• Additive extensibility.
• Reduced coupling between surfaces.
• Extensions subscribe only to the context they require.
Option C — Route-Only Context
The host exposes only routing information.
Chatbot providers independently reconstruct context through APIs or backend services.
Rejected Because
This approach cannot reliably represent transient frontend state.
Examples include:
• Unsaved chart edits.
• Temporary dashboard filters.
• Active dashboard tabs.
• SQL editor state.
• Draft configuration changes.
As a result, chatbot context would frequently drift from what the user is actually viewing.
Decision
Option B is selected.
The host owns context normalization while extensions own context composition.
This preserves separation of concerns, minimizes coupling, and provides a stable foundation for future extension capabilities.
6.3 D2 — Singleton Chatbot Resolution
When multiple chatbot extensions are installed, the host must determine which chatbot is rendered.
This decision shapes both the rendering model and the extension isolation model.
Option A — Expose Providers Through getViews()
Allow:
getViews(location);
to return both metadata and rendering providers.
Rejected Because
Rendering providers are executable logic.
Exposing providers would allow one extension to:
• Render another extension.
• Bypass host lifecycle management.
• Circumvent fault isolation.
• Assume ownership of another extension's UI.
This violates a deliberate separation between extension discovery and extension execution.
Option B — Host-Managed Provider Resolution (Selected)
The host exposes only metadata publicly while retaining provider resolution internally.
Conceptually:
const provider = getViewProvider("superset.chatbot", selectedId);
Chatbot selection is handled through a host-managed policy:
const chatbot = getActiveChatbot(adminSelectedId, enabledMap);
Advantages
• Preserves extension isolation.
• Preserves host ownership of rendering.
• Supports administrative selection.
• Supports enablement checks.
• Supports future policy evolution.
Option C — Reuse resolveView()
Use the existing rendering helper:
resolveView(id);
to render chatbot providers.
Rejected Because
resolveView() assumes the caller already knows which view should be rendered.
It does not account for:
• Administrative selection.
• Enablement state.
• Settings synchronization.
• Chatbot-specific resolution policy.
Decision
Option B is selected.
The host owns chatbot selection and rendering.
The registry remains a discovery mechanism rather than a rendering mechanism.
Architectural Principle
A core principle established by this SIP is:
"Extensions may discover registered views, but only the host may render registered views."
This preserves extension isolation and prevents cross-extension rendering dependencies.
6.4 D3 — Descriptor Metadata Ownership
Chatbot registrations may include metadata used by administrative and discovery interfaces.
A key question is whether descriptor metadata should be static or runtime-updatable.
Option A — Static Descriptor Metadata (Selected)
Metadata is defined at registration time and remains unchanged for the lifetime of the registration.
Example:
{
id: 'acme.chatbot',
name: 'Acme Chatbot',
icon: 'Bubble',
}
Advantages
• Simpler registry implementation.
• Clear ownership model.
• Consistent administration UI.
• No registry update lifecycle.
Option B — Runtime-Updatable Metadata
Extensions can update descriptor metadata after registration.
Examples:
• Notification badges.
• Thinking indicators.
• Dynamic branding.
Rejected Because
These states belong to the chatbot user interface rather than the registration descriptor.
Supporting dynamic metadata would:
• Increase registry complexity.
• Introduce update synchronization concerns.
• Provide limited benefit for current consumers.
Decision
Option A is selected.
Descriptor metadata remains static.
Dynamic UI state remains the responsibility of the chatbot component.
Future requirements for dynamic metadata can be addressed independently if needed.
6.5 D4 — Administration Scope
This SIP introduces deployment-wide chatbot administration.
A key question is whether availability should be deployment-scoped or user-scoped.
Option A — Deployment-Wide Administration (Selected)
Administrators manage:
• Extension availability.
• Default chatbot selection.
These settings apply to the entire deployment.
Advantages
• Clear administrative ownership.
• Simple operational model.
• Consistent with existing extension administration patterns.
• Avoids introducing multiple configuration layers.
Option B — User-Scoped Availability
Availability and chatbot selection become user-specific settings.
Rejected Because
Administrative availability and user preference represent different concerns.
Administrators answer:
"Which integrations are available in this deployment?"
Users answer:
"Which available integrations do I prefer?"
Combining these concerns into a single model creates unclear ownership and duplicated configuration responsibilities.
Decision
Option A is selected.
This SIP introduces only deployment-wide administration.
Future user preferences should be layered on top using the following model:
Effective Availability = Deployment Availability AND User Preference
The recommended persistence mechanism for user-specific preferences is the Extension Storage API.
This approach aligns with SIP-127 and preserves a clear separation between administrative configuration and user customization.
7. Risks and Future Considerations
The selected architecture introduces several tradeoffs.
Namespace Maintenance
As additional application surfaces become extension-aware, new namespaces may be required.
This increases the maintenance burden of the extension API surface.
Contract Evolution
Namespace contracts are intended to be stable.
Over time, extensions may require additional context that is not initially exposed.
Future additions must preserve compatibility and avoid leaking implementation details.
Context Growth
Dashboard and chart context may become increasingly rich over time.
Care must be taken to ensure context APIs remain focused and do not evolve into large aggregate objects.
Extension Expectations
Chatbot vendors may request direct access to internal application state for convenience.
This SIP intentionally rejects that approach in favor of stable semantic contracts.
Maintaining that boundary may require additional namespace evolution over time. 8. Open Questions
D5 — Per-Page Visibility
Should chatbot extensions be able to declare page visibility constraints?
Two approaches remain possible.
Extension-Controlled Visibility
Extensions observe:
navigation.onDidChangePage(...)
and decide whether to render themselves.
Host-Enforced Visibility
Extensions declare supported page types through manifest metadata and the host enforces visibility.
Recommendation
Defer this decision.
The current architecture already supports extension-controlled visibility without requiring additional platform capabilities.
D6 — Generalized Floating Contribution Areas
The current proposal introduces a chatbot-specific contribution location:
superset.chatbot
A future question is whether this should evolve into a more generic floating-widget framework.
Examples might include:
• Chatbots.
• Guided tours.
• Notification centers.
• Productivity assistants.
Recommendation
Keep the contribution area chatbot-specific.
If broader floating-widget requirements emerge, introduce a dedicated abstraction rather than expanding the scope of this SIP.
9. Related Documents
Contribution types
Client actions
The following proposals are related to this SIP.
Extension Storage API
Add storage API for extensions (#39171)
Introduces namespace-isolated storage for extensions with support for:
• Local storage.
• Session storage.
• Ephemeral server storage.
• Persistent database-backed storage.
This proposal is complementary to the administration model defined by this SIP and is the recommended foundation for future user-specific extension preferences.
SIP-127 — User Preferences
[SIP-127] User Preferences (#28047)
Establishes the per-user preference model used by Superset core.
The Extension Storage API serves as the extension-scoped equivalent of this pattern and provides the recommended approach for future user-specific chatbot preferences. 10. Migration Plan
Base branch enxdev/chat-prototype
Branch for testing test/chatbot-local
The following capabilities are required to fully realize this SIP.
Core Platform Changes
Implemented
• superset.chatbot contribution location.
• Host-side chatbot resolution.
• Administration UI for chatbot selection.
• Dashboard namespace.
• Explore namespace.
• Navigation namespace.
• Runtime settings synchronization.
Pending
• Dataset namespace implementation.
• Dashboard chart visibility context.
• Permission-scoped dashboard context endpoint.
• Manifest support for application-level contribution scopes.
• Optional descriptor icon support.
11. Implementation Phases
Phase 1 — Chatbot Mount Point
• Chatbot contribution location.
• Host-side rendering.
• Lifecycle management.
• Fault isolation.
Status: Complete
Phase 2 — Administration
• Enable/disable support.
• Default chatbot selection.
• Runtime synchronization.
Status: Complete
Phase 3 — Context APIs
• Dashboard namespace.
• Explore namespace.
• Navigation namespace.
• Dataset namespace.
Status: Partially Complete
Remaining work:
• Dataset namespace.
• Dashboard chart visibility context.
• Dashboard context endpoint.
Phase 4 — Client Actions
Client actions and agentic UI interactions remain outside the scope of this SIP and are expected to be addressed through a separate proposal.

322
TICKETS.md Normal file
View File

@@ -0,0 +1,322 @@
# Chatbot Extensions — Tickets
Lightweight, pre-implementation tickets. Each says what to build and where the
boundaries are; it does not prescribe the final code. Stack order (bottom → top):
contribution point → frontend API mount → eager loading → admin UI → backend
settings/permissions → context sharing → import/delete.
---
## 1. Define the contribution point
**Goal:** Introduce the `superset.chatbot` contribution area and the host plumbing
needed to mount a single chatbot at the application-shell level, persistent across
routes. This is the keystone everything else builds on.
**Build:**
- Register `superset.chatbot` as a recognized contribution location in the view
registry.
- Add an app-shell / app-root contribution scope to the extension manifest schema
so the location can be declared in `extension.json` (the current schema is
SQL-Lab-only). Teach both manifest validation and runtime registration about it.
- Provide an exclusive-location resolver that selects exactly one renderable
chatbot for the slot, with a deterministic first-to-register fallback and a seam
for an externally supplied "active chatbot id" (so admin/runtime policy can plug
in later without touching the resolver).
- Host-managed mount layout: fixed bottom-right, 24px margin, z-index above content
and toasts, below modals.
**Out of scope:** fault isolation, admin selection UI, the lifecycle/teardown
contract, eager loading, streaming, context namespaces, authoring docs.
**Depends on:** nothing — this unblocks the rest.
**Done when:** an extension can register at `superset.chatbot` and render at the
app shell across routes; the resolver returns one provider (admin-id seam +
first-to-register fallback); unregistering removes the mount cleanly with no
duplicate bubbles.
Base branch: `enxdev/chat-prototype`
**External Links:** https://github.com/apache/superset/pull/40439
---
## 2. Host resolution & mount (frontend API entry point)
**Goal:** Turn a registered `superset.chatbot` view into a rendered, fault-isolated
bubble — the host-internal provider accessor, the selection policy, and the
fixed-position mount.
**Build:**
- Host-internal accessors on the views registry: `getViewProvider(location, id)`
and `getRegisteredViewIds(location)`. Keep the public `getViews` descriptor-only —
do not expose providers on the public surface.
- A registry change subscription so a mount can re-resolve without polling (fired
on register/unregister).
- The `getActiveChatbot(adminSelectedId?, enabledMap?)` resolver implementing the
selection policy: empty → none; drop disabled ids; admin-selected-and-enabled
wins; else first enabled in registration order.
- A `ChatbotMount` component at the app shell that renders the active provider
inside the host `ErrorBoundary`, re-resolves on registry change, and renders
nothing when no chatbot is active.
**Out of scope:** the contribution location itself (#40439); eager-loading the
bundle (#40441); the settings endpoint (#40443, consumed here with silent
fallback); admin UI; the lifecycle/teardown contract.
**Depends on:** #40439 (imports `CHATBOT_LOCATION`). The settings endpoint is a soft
forward-dependency — the mount falls back to first-registered-enabled if it 404s.
**Done when:** the provider accessor and resolver behave per the policy; the mount
renders/clears correctly and survives a throwing provider via `ErrorBoundary`;
`getViews` stays descriptor-only.
Base branch: `enxdev/feat/chatbot-contribution-point` (on #40439)
**External Links:** https://github.com/apache/superset/pull/40440
---
## 3. Eager loading & extension lifecycle/teardown
> Merged: this ticket also covers the **lifecycle & teardown contract** — both are
> implemented in the same PR (#40441), so they are tracked together.
**Goal:** Boot extension bundles at app-shell startup so contributions register
before the first route, and define the host contract for tearing those
contributions down on uninstall.
**Build — eager loading:**
- An `ExtensionsStartup` component that, once the session is confirmed and behind
`FeatureFlag.EnableExtensions`, kicks off `initializeExtensions()` in the
background. The host renders immediately; the mount re-resolves reactively when
registrations land.
- Wire `window.superset` so Module-Federation remotes can consume host namespaces.
- Mount `<ChatbotMount />` as a sibling of the route switch, inside
`ExtensionsStartup`.
- On bundle-load failure: a danger toast, host stays interactive, corner stays
empty. Add a global `unhandledrejection` logger (log only; do not suppress the
browser default).
**Build — lifecycle/teardown contract (Model A1, per-contribution dispose):**
- During the `./index` factory call, intercept the public registrars and collect
the returned `Disposable`s keyed by extension id.
- `deactivateExtension(id)` is the single teardown entrypoint: it fires every
collected `Disposable` and removes the extension from the index. A throwing
`Disposable` must not block the others (catch per-disposable). Idempotent;
unknown id is a no-op.
- Trigger semantics to document: **uninstall**`deactivateExtension(id)`;
**disable** → mount filters by `enabledMap`, does NOT fire disposables, re-enable
needs no reload; **replace** (singleton) → resolver re-selects, the losing
extension is not deactivated. Disposal order is best-effort (registration order),
not a contract — consumers must be order-independent.
**Out of scope:** selective per-type eager loading (not feasible without running the
factory); the mount-boundary `ErrorBoundary` (#40440); the settings endpoint and
its subscription primitive (#40443); context namespaces (#40444); an async-aware
`deactivate(): Promise<void>` — file separately only if a graceful-flush
requirement appears.
**Depends on:** #40440 (`ChatbotMount`, resolver, registry-subscription hook). Soft
build-time deps on #40443 (settings subscription) and #40444 (namespaces) — land
those first or stub the imports.
**Done when:** enabled extensions init once at startup behind the flag without
gating initial render; the bubble appears reactively on registration;
`deactivateExtension(id)` disposes all of an extension's contributions (per-disposable
catch, idempotent); load failure toasts without throwing; teardown is verified
end-to-end on the reference chatbot (including an abort-registry controller that must
still abort on deactivate).
Base branch: `enxdev/feat/chatbot-frontend-api` (on #40440)
**External Links:** https://github.com/apache/superset/pull/40441
---
## 4. Admin configuration UI
**Goal:** Let an admin enable/disable the chatbot and, when more than one chatbot is
installed, choose which is active.
**Resolve before building:**
- Is "disable the chatbot" the existing generic extension-disable, or a
chatbot-specific toggle? (Determines the ticket's size — prefer reusing the
existing flag.)
- Where does the UI live? Default: the existing extensions management surface, not a
new page.
- How does the "default chatbot" selection persist? Reuse existing extension-state
storage or a config value — do not invent a table.
- Which permission gates it? Default: the existing Extensions-API write permission.
**Build:**
- Enable/disable control that empties the `superset.chatbot` slot when off (no broken
placeholder).
- (Gated on the singleton-policy decision) A selection control listing candidates via
`getViews('superset.chatbot')`, activating the choice through the resolver, falling
back to first-to-register when unset.
- Switching the active chatbot or disabling it at runtime must dispose the previously
active chatbot via its `Disposable` and release its in-flight stream readers (via
`AbortController`) — no two bubbles, no leaked readers.
**Out of scope:** the singleton-policy decision itself; per-page visibility; the
resolver implementation (consumed here).
**Depends on:** #40440 (resolver) for selection; enable/disable does not wait on the
policy decision.
**Done when:** admin can enable/disable (gated by the chosen permission) and the slot
empties when off; selection picks the active chatbot with first-to-register fallback;
the persistence-mechanism and permission decisions are recorded in the ticket.
Base branch: `enxdev/chat-prototype`
**External Links:** https://github.com/apache/superset/pull/40442
---
## 5. Permissions
**Goal:** Guarantee the new page-context surface cannot expose anything the current
user can't already access through Superset's standard security model. Chatbot
extensions fetch data as any other frontend surface and inherit only the current
user's privileges; this ticket covers only the new host → extension context-sharing
path.
**Build:**
- The page-context namespaces (#40444) must derive entity metadata from the same
permission checks that gate the underlying page — not a raw Redux pass-through.
- Canonical threat (SIP §2.1): a dashboard the user can view that contains a chart
whose dataset they cannot query — that chart's metadata (id, name, datasource,
viz type, form_data) must be dropped from the context payload.
- Context carries only lightweight semantic data + identifiers that resolve through
already-protected APIs; never inline dataset rows or query results.
- Filtering applies equally to the initial read and every change-notification
payload. A chatbot an admin has disabled receives no context at all.
**Out of scope:** REST API authorization, RBAC, RLS (already enforced by Superset);
LLM/backend auth; the singleton selection policy. The chatbot authenticates via the
user's existing session (cookie + CSRF) — no separate credential is issued.
**Depends on:** the Spike sizing the new namespaces (the per-getter filtering lands
with those getters); the Context-sharing ticket consumes the filtered getters this
one specifies.
**Done when:** context never exposes entities/ids/metadata the user can't access
(even via a manually-entered URL); the dashboard payload omits charts whose dataset
the user can't query; no inline privileged payloads; filtering covers change events
as well as the initial read; a disabled chatbot gets nothing.
Base branch: `enxdev/chat-prototype`
**External Links:** https://github.com/apache/superset/pull/40443
---
## 6. Context sharing
**Goal:** Let the chatbot read semantic page context and subscribe to changes through
public per-surface core namespaces only — never the host Redux store.
**Approach:** Deliver context through per-surface namespaces — the existing `sqlLab`
namespace plus new `dashboard` / `explore` / `navigation` namespaces that mirror its
shape (a state getter + an `Event<T>` change subscription). No new aggregate context
API. The new namespaces copy `sqlLab`'s shape but must filter the Redux state they
read (the permission filtering itself is specified by #40443).
**Build:**
- Route all chatbot page-context reads through one narrow adapter module with a fixed
interface — the adapter is the deliverable, not scattered call sites — so swapping
to core namespaces is a one-line change.
- Back the adapter with `sqlLab` immediately; back the dashboard/explore/navigation
portions and wire change notifications through `navigation`'s page-change event once
those namespaces ship.
**Out of scope:** the permission-filtering logic (#40443 + upstream namespace work);
designing the namespace API surface (upstream OSS work, sized by the Spike).
**Depends on:** a Spike to size the new namespaces (state getters + events + the
per-getter permission filtering). The namespace _shape_ is settled by the `sqlLab`
precedent; the filtering is real design work.
**Done when:** all context reads go through the single adapter (zero direct Redux
imports, greppable); SQL Lab context works today; dashboard/explore context is either
delivered or explicitly tracked as OSS-blocked (not faked); change notifications need
no polling; no extra host re-renders.
Base branch: `enxdev/chat-prototype`
**External Links:** https://github.com/apache/superset/pull/40444
---
## 7. Import / delete UI
**Goal:** Add an actions column to the extensions list with buttons to delete an
extension, set-as-default (chatbot extensions only), and import a new extension.
**Build:**
- Import an extension bundle, refreshing the list on success.
- Delete an installed extension.
- A "set as default chatbot" control, shown only for chatbot extensions.
**Out of scope:** the settings endpoint itself (#40443); the resolver (#40440).
**Depends on:** #40442/#40443 for the settings + chatbot-selection plumbing.
**Done when:** an admin can import, delete, and set a default chatbot from the
actions column, with the list reflecting changes.
Base branch: `enxdev/chat-prototype`
**External Links:** https://github.com/apache/superset/pull/40450
---
## ~~8. Fault isolation & error boundaries~~ — CLOSED (no ticket needed)
The protective fault-isolation mechanisms are **already implemented** across the
mount and eager-loading PRs, so no standalone ticket is required:
- Render/lifecycle throw → host `ErrorBoundary` around the `superset.chatbot` slot
(#40440, reinforced by the `ChatbotRenderer` wrapper in #40441).
- Bundle-load failure → `.catch()` + danger toast in `ExtensionsLoader` (#40441).
- `activate()` throw → host try/catch in `ExtensionsLoader` (#40441).
- Escaped async rejection → `unhandledrejection` hook in `ExtensionsStartup` (#40441).
- Failed-activation cleanup → driven by `deactivateExtension` (ticket 3 / #40441).
The host stays safe under every failure class today. The only unbuilt pieces were the
**optional** "chatbot failed — Reload page" notification and structured
failure-class/telemetry logging — both judged not worth a ticket (the original spec
itself marked the reload notification "optional"). File a fresh ticket only if that
UX is later wanted.
(Original link, for reference only: PR #40433 `feat(extensions): adds chatbot P1-P2`
closed/superseded; never a dedicated fault-isolation PR.)
---
## Notes on consolidation
- **Lifecycle/teardown** was a separate ticket pointing at the same PR as **Eager
loading** (#40441) — merged into ticket 3 above. (This is the only true duplicate.)
- The **Permissions** ticket (#40443) is kept as-is. Note its PR also contains
backend settings-persistence code, but the original ticket only ever scoped the
permission-safe context surface — so the ticket stays "Permissions" and no
persistence ticket is invented.
- The **Permissions** ticket previously had a truncated base branch
(`enxdev/chat-protot`) — corrected to `enxdev/chat-prototype`.
- **Fault isolation** was **closed without a ticket** (see the struck-through section
above): its protective mechanisms already shipped in #40440/#40441, and the only
unbuilt pieces (the optional "Reload page" notification + structured telemetry
logging) were judged not worth a ticket.

138
extensions/chat/README.md Normal file
View File

@@ -0,0 +1,138 @@
<!--
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.
-->
# Reference Chatbot Extension
Canonical environment-validation extension for the `superset.chatbot`
contribution area. **Not** a product chatbot — there is no LLM, no backend,
no persistence. Its purpose is to exercise the extension platform end-to-end:
- `views.registerView` at `superset.chatbot` (singleton resolution)
- Lifecycle activation + a master disposable that tears down everything
- `commands.registerCommand` for `core.chatbot__open|close|toggle`
- Mock streaming with `AbortController` cancellation on dispose
- Defense-in-depth React error boundary inside the panel
- A single P3 page-context seam that lights up automatically as the
`dashboard` / `explore` / `dataset` / `navigation` namespaces become
available at runtime on the host
It is intended as the reference implementation third-party chatbot extension
authors copy. Anything that ships as host-internal (the mount point, the
admin picker, the `getActiveChatbot` resolver) is **not** here — see the
host side at `superset-frontend/src/components/ChatbotMount/` and
`superset-frontend/src/core/chatbot/`.
## Layout
```
extensions/chat/
├── extension.json Manifest (app.chatbot view + commands)
├── package.json
├── tsconfig.json
├── webpack.config.js ModuleFederation → window.superset
├── jest.config.js Self-contained unit tests
└── src/
├── index.tsx MF entry — calls activate() once
├── activate.ts Returns master disposable
├── commands.ts core.chatbot__open|close|toggle
├── state.ts Module-scoped open/closed + emitter
├── ReferenceChatbot.tsx Root component (bubble ↔ panel)
├── components/
│ ├── Bubble.tsx
│ ├── Panel.tsx
│ └── ErrorBoundary.tsx
├── streaming/
│ ├── mockStream.ts AsyncIterable<string> + AbortSignal
│ └── registry.ts Cross-component abort tracking
├── context/
│ └── pageContext.ts P3 namespace seam (defensive)
└── __tests__/
├── sdkMock.ts In-memory @apache-superset/core mock
└── activate.test.tsx
```
## Run the unit tests
```bash
cd extensions/chat
npm install # first time only
npx jest
```
The tests mock `@apache-superset/core` via `src/__tests__/sdkMock.ts` so they
do not depend on host runtime wiring.
## Build / bundle for deployment
```bash
# from the extension folder
npm install
npm run build
# packaging into a .supx is handled by the Superset extensions CLI
pip install apache-superset-extensions-cli
superset-extensions bundle # produces apache-superset.reference-chatbot-0.1.0.supx
```
Drop the `.supx` into the `EXTENSIONS_PATH` of a Superset instance that has
`FEATURE_FLAGS = { "ENABLE_EXTENSIONS": True }`.
## Selecting it as the active chatbot
The host's singleton picker reads `active_chatbot_id` from the admin
settings endpoint (`/api/v1/extensions/settings`). Set it to:
```
apache-superset.reference-chatbot
```
If no admin selection exists, the host falls back to the first-to-register
chatbot — installing this extension alone is enough for the bubble to appear.
## P3 integration seams
All page-context derivation lives in [`src/context/pageContext.ts`](src/context/pageContext.ts).
Each namespace branch (`dashboard`, `explore`, `dataset`, `navigation`) is
called defensively — when the host implementation lands, the returned value
becomes non-undefined automatically with no other change in the extension.
The panel re-reads context on `popstate`. Once `navigation.onDidChangePage`
is live on the host, the panel's `useEffect` should subscribe to it instead;
that is the only file in the extension that needs to change for full P3
context sync.
## Known intentional non-features
- No conversation persistence — by design (extension scope per SIP §2).
- No real network. The mock stream is a `setTimeout` token emitter so the
cancellation contract is exercised without external dependencies.
- No keyboard shortcut binding (Cmd+K). Extensions own that, but it adds
surface area not needed for platform validation.
- No notification badge / icon mutation. SIP §3.2 recommends static icons;
the bubble re-renders freely already.
## TODOs
- **P1**: if/when the host gains `deactivate(): Promise<void>`, wrap the
master disposer in `activate.ts` to flush async work before returning.
- **P3**: replace the `popstate` listener in `Panel.tsx` with
`navigation.onDidChangePage` once that event is wired up host-side.
- **P4**: if the host pre-registers `core.chatbot__*` as host-owned intents,
swap `commands.registerCommand` for the implementation hook in
`commands.ts`. Command IDs do not change.

View File

@@ -0,0 +1,40 @@
{
"publisher": "apache-superset",
"name": "reference-chatbot",
"displayName": "Reference Chatbot",
"description": "Canonical environment-validation chatbot extension for the superset.chatbot contribution area. Exercises registration, lifecycle, singleton resolution, commands, fault isolation, and streaming teardown. Not a product chatbot.",
"version": "0.1.0",
"license": "Apache-2.0",
"permissions": [],
"contributes": {
"views": {
"app": {
"chatbot": [
{
"id": "apache-superset.reference-chatbot",
"name": "Reference Chatbot",
"description": "Validates the chatbot extension environment end-to-end.",
"icon": "Bubble"
}
]
}
},
"commands": [
{
"id": "core.chatbot__open",
"title": "Open chatbot",
"description": "Opens the reference chatbot panel."
},
{
"id": "core.chatbot__close",
"title": "Close chatbot",
"description": "Closes the reference chatbot panel."
},
{
"id": "core.chatbot__toggle",
"title": "Toggle chatbot",
"description": "Toggles the reference chatbot panel."
}
]
}
}

View File

@@ -0,0 +1,53 @@
/**
* 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.
*/
const path = require('path');
// When run as a standalone package (`cd extensions/chat && npm test`), modules
// resolve from this folder's own node_modules. When run from the superset-frontend
// workspace (CI, dev convenience), resolve ts-jest there too.
const tsJest = (() => {
try {
require.resolve('ts-jest');
return 'ts-jest';
} catch {
return path.resolve(
__dirname,
'..',
'..',
'superset-frontend',
'node_modules',
'ts-jest',
);
}
})();
module.exports = {
testEnvironment: 'jsdom',
rootDir: __dirname,
testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
// When running from the extension folder without node_modules installed,
// resolve react / react-dom from the superset-frontend workspace.
modulePaths: [path.resolve(__dirname, '..', '..', 'superset-frontend', 'node_modules')],
moduleNameMapper: {
'^@apache-superset/core$': '<rootDir>/src/__tests__/sdkMock.ts',
},
transform: {
'^.+\\.tsx?$': [tsJest, { tsconfig: '<rootDir>/tsconfig.test.json' }],
},
};

View File

@@ -0,0 +1,26 @@
{
"name": "@apache-superset/reference-chatbot",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"description": "Reference chatbot extension that validates the Superset chatbot extension platform.",
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --stats-error-details --mode production"
},
"peerDependencies": {
"@apache-superset/core": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@apache-superset/core": "^0.1.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"ts-loader": "^9.5.0",
"typescript": "^5.0.0",
"webpack": "^5.0.0",
"webpack-cli": "^5.0.0",
"webpack-dev-server": "^5.0.0"
}
}

View File

@@ -0,0 +1,45 @@
/**
* 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 React, { useEffect, useState } from 'react';
import { commands } from '@apache-superset/core';
import { Bubble } from './components/Bubble';
import { Panel } from './components/Panel';
import { ExtensionErrorBoundary } from './components/ErrorBoundary';
import { isOpen, subscribe } from './state';
/**
* Root extension component. Mirrors module-state into React via `subscribe`
* so the bubble↔panel transition is driven by the same command handlers
* that external callers use (`core.chatbot__open`, `__close`, `__toggle`).
*/
export const ReferenceChatbot: React.FC = () => {
const [open, setOpenState] = useState<boolean>(isOpen());
useEffect(() => subscribe(setOpenState), []);
return (
<ExtensionErrorBoundary>
{open ? (
<Panel onClose={() => commands.executeCommand('core.chatbot__close')} />
) : (
<Bubble onClick={() => commands.executeCommand('core.chatbot__open')} />
)}
</ExtensionErrorBoundary>
);
};

View File

@@ -0,0 +1,144 @@
/**
* 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 { commands } from '@apache-superset/core';
import { registry, reset } from './sdkMock';
import { activate, VIEW_ID, CHATBOT_LOCATION } from '../activate';
import { isOpen } from '../state';
import { streamReply } from '../streaming/mockStream';
import {
registerActiveController,
unregisterActiveController,
abortAllActiveControllers,
} from '../streaming/registry';
beforeEach(() => {
reset();
});
test('registers one view at superset.chatbot and three chatbot commands', () => {
const disposable = activate();
try {
expect(registry.views.size).toBe(1);
const entry = registry.views.get(VIEW_ID);
expect(entry?.location).toBe(CHATBOT_LOCATION);
expect(entry?.view.icon).toBe('Bubble');
expect(Array.from(registry.commands.keys()).sort()).toEqual([
'core.chatbot__close',
'core.chatbot__open',
'core.chatbot__toggle',
]);
} finally {
disposable.dispose();
}
});
test('executeCommand drives open/close/toggle through module state', async () => {
const disposable = activate();
try {
expect(isOpen()).toBe(false);
await commands.executeCommand('core.chatbot__open');
expect(isOpen()).toBe(true);
await commands.executeCommand('core.chatbot__toggle');
expect(isOpen()).toBe(false);
await commands.executeCommand('core.chatbot__toggle');
expect(isOpen()).toBe(true);
await commands.executeCommand('core.chatbot__close');
expect(isOpen()).toBe(false);
} finally {
disposable.dispose();
}
});
test('disposing the master disposable unregisters view + commands', () => {
const disposable = activate();
expect(registry.views.size).toBe(1);
expect(registry.commands.size).toBe(3);
disposable.dispose();
expect(registry.views.size).toBe(0);
expect(registry.commands.size).toBe(0);
});
test('disposal is idempotent', () => {
const disposable = activate();
disposable.dispose();
expect(() => disposable.dispose()).not.toThrow();
expect(registry.views.size).toBe(0);
});
test('re-activate after dispose works (validates replace semantics)', () => {
const first = activate();
first.dispose();
const second = activate();
try {
expect(registry.views.size).toBe(1);
expect(registry.commands.size).toBe(3);
expect(isOpen()).toBe(false); // resetState() cleared open flag
} finally {
second.dispose();
}
});
test('aborting an active controller stops the stream cleanly', async () => {
const controller = new AbortController();
registerActiveController(controller);
const iter = streamReply('hello world', controller.signal);
const received: string[] = [];
const consume = (async () => {
for await (const tok of iter) received.push(tok);
})();
// Abort after a single tick — the iterator must return without throwing.
await new Promise(r => setTimeout(r, 50));
abortAllActiveControllers();
await expect(consume).resolves.toBeUndefined();
unregisterActiveController(controller);
expect(received.length).toBeLessThan(20); // would be ~20+ tokens if uncancelled
});
test('disposing the extension aborts any in-flight controller', async () => {
const disposable = activate();
const controller = new AbortController();
registerActiveController(controller);
const iter = streamReply('a longer prompt to ensure many tokens', controller.signal);
const consume = (async () => {
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
for await (const _tok of iter) {
// drain
}
})();
await new Promise(r => setTimeout(r, 30));
disposable.dispose();
await expect(consume).resolves.toBeUndefined();
expect(controller.signal.aborted).toBe(true);
});

View File

@@ -0,0 +1,119 @@
/**
* 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.
*/
/**
* In-memory mock of `@apache-superset/core` for unit-testing the extension.
*
* Mirrors only the surfaces the reference chatbot consumes:
* - views.registerView returns a disposable that removes the view
* - commands.registerCommand / executeCommand round-trip handlers
* - sqlLab.getCurrentTab returns undefined (no SQL Lab in tests)
*
* The mock is intentionally observable: tests can read `registry.views` and
* `registry.commands` to assert contract compliance.
*/
import type { ReactElement } from 'react';
type Provider = () => ReactElement;
interface ViewDescriptor {
id: string;
name: string;
icon?: string;
description?: string;
}
interface DisposableLike {
dispose(): void;
}
interface RegisteredView {
view: ViewDescriptor;
location: string;
provider: Provider;
}
interface RegisteredCommand {
id: string;
title: string;
handler: (...args: any[]) => any;
}
export const registry = {
views: new Map<string, RegisteredView>(),
commands: new Map<string, RegisteredCommand>(),
};
export const reset = (): void => {
registry.views.clear();
registry.commands.clear();
};
export const views = {
registerView(
view: ViewDescriptor,
location: string,
provider: Provider,
): DisposableLike {
registry.views.set(view.id, { view, location, provider });
return {
dispose: () => {
registry.views.delete(view.id);
},
};
},
getViews(location: string) {
return Array.from(registry.views.values())
.filter(v => v.location === location)
.map(v => v.view);
},
};
export const commands = {
registerCommand(
command: { id: string; title: string },
handler: (...args: any[]) => any,
): DisposableLike {
registry.commands.set(command.id, {
id: command.id,
title: command.title,
handler,
});
return {
dispose: () => {
registry.commands.delete(command.id);
},
};
},
async executeCommand(id: string, ...rest: any[]): Promise<unknown> {
const cmd = registry.commands.get(id);
return cmd?.handler(...rest);
},
getCommands() {
return Array.from(registry.commands.values()).map(c => ({
id: c.id,
title: c.title,
}));
},
};
export const sqlLab = {
getCurrentTab: () => undefined,
};

View File

@@ -0,0 +1,88 @@
/**
* 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 React from 'react';
import { views } from '@apache-superset/core';
import { ReferenceChatbot } from './ReferenceChatbot';
import { registerChatbotCommands } from './commands';
import { abortAllActiveControllers } from './streaming/registry';
import { resetState } from './state';
export const VIEW_ID = 'apache-superset.reference-chatbot';
export const CHATBOT_LOCATION = 'superset.chatbot';
interface DisposableLike {
dispose(): void;
}
/**
* Registers the reference chatbot and returns a single disposable that
* tears down everything it created. Idempotent across activate/dispose cycles.
*
* Cleanup order matters: stop in-flight streams first so listeners do not
* receive late tokens, then unregister commands (so user clicks during teardown
* become no-ops), then unregister the view (so the host's ChatbotMount unmounts
* the React tree), and finally reset module state.
*
* Returns a plain `{ dispose }` object rather than constructing a Disposable
* from the SDK — the SDK class is host-injected and only reliably available
* via window.superset at runtime, while plain disposable-likes work in both
* runtime and unit-test contexts.
*
* TODO(P1): when the host gains an async `deactivate(): Promise<void>` hook,
* wrap the master disposer to flush in-flight async work before returning.
*/
export const activate = (): DisposableLike => {
const commandDisposables = registerChatbotCommands();
const viewDisposable = views.registerView(
{
id: VIEW_ID,
name: 'Reference Chatbot',
icon: 'Bubble',
description: 'Validates the chatbot extension environment end-to-end.',
},
CHATBOT_LOCATION,
() => React.createElement(ReferenceChatbot),
);
let disposed = false;
return {
dispose() {
if (disposed) return;
disposed = true;
try {
abortAllActiveControllers();
} catch {
// streams are best-effort during teardown
}
commandDisposables.forEach(d => {
try {
d.dispose();
} catch {
// a single command failing to unregister must not block the rest
}
});
try {
viewDisposable.dispose();
} catch {
// ignore
}
resetState();
},
};
};

View File

@@ -0,0 +1,47 @@
/**
* 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 { commands } from '@apache-superset/core';
import { isOpen, setOpen } from './state';
interface DisposableLike {
dispose(): void;
}
/**
* Registers the three chatbot intent commands and returns their disposables.
*
* TODO(P4): if/when the host pre-registers `core.chatbot__*` as host-owned
* intents that extensions implement instead of own, swap registerCommand for
* the implementation hook. The command ids stay the same so call sites do not
* change.
*/
export const registerChatbotCommands = (): DisposableLike[] => [
commands.registerCommand(
{ id: 'core.chatbot__open', title: 'Open chatbot' },
() => setOpen(true),
),
commands.registerCommand(
{ id: 'core.chatbot__close', title: 'Close chatbot' },
() => setOpen(false),
),
commands.registerCommand(
{ id: 'core.chatbot__toggle', title: 'Toggle chatbot' },
() => setOpen(!isOpen()),
),
];

View File

@@ -0,0 +1,46 @@
/**
* 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 React from 'react';
interface Props {
onClick: () => void;
}
export const Bubble: React.FC<Props> = ({ onClick }) => (
<button
type="button"
onClick={onClick}
data-test="reference-chatbot-bubble"
aria-label="Open reference chatbot"
style={{
width: 56,
height: 56,
borderRadius: '50%',
border: 'none',
background: '#1f6feb',
color: '#fff',
fontSize: 24,
fontWeight: 600,
cursor: 'pointer',
boxShadow: '0 4px 14px rgba(0,0,0,0.18)',
}}
>
?
</button>
);

View File

@@ -0,0 +1,66 @@
/**
* 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 React from 'react';
interface State {
error: Error | null;
}
/**
* Defense-in-depth boundary. The host already wraps the mount in its own
* ErrorBoundary; this one keeps a panel crash from also bringing down the
* bubble next to it.
*/
export class ExtensionErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
State
> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error): void {
// eslint-disable-next-line no-console
console.error('[reference-chatbot] render error', error);
}
render() {
if (this.state.error) {
return (
<div
data-test="reference-chatbot-error"
style={{
padding: 12,
border: '1px solid #f5222d',
borderRadius: 6,
background: '#fff1f0',
color: '#a8071a',
fontSize: 12,
maxWidth: 320,
}}
>
Reference chatbot crashed: {this.state.error.message}
</div>
);
}
return <>{this.props.children}</>;
}
}

View File

@@ -0,0 +1,307 @@
/**
* 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamReply } from '../streaming/mockStream';
import { getPageContext, PageContext, subscribeToPageChanges } from '../context/pageContext';
import { registerActiveController, unregisterActiveController } from '../streaming/registry';
interface Props {
onClose: () => void;
}
interface Message {
id: number;
from: 'user' | 'bot';
text: string;
}
let messageSeq = 0;
/**
* Builds the full set of context fields the host exposes for the current
* surface, as ordered [label, value] rows. Whatever the host provides for where
* the user is, the panel shows — nothing is summarized away. Returns an empty
* array for surfaces with no active entity (list/home pages), where the
* `page:` line alone is the context.
*/
const contextRows = (ctx: PageContext): Array<[string, string]> => {
const rows: Array<[string, string]> = [];
const push = (label: string, value: unknown) => {
if (value !== undefined && value !== null && value !== '') {
rows.push([label, String(value)]);
}
};
const chart = ctx.chart as
| {
chartId?: number | null;
chartName?: string | null;
vizType?: string;
datasourceId?: number | null;
datasourceName?: string | null;
}
| undefined;
if (chart) {
push('chart', chart.chartName ?? (chart.chartId == null ? '(unsaved)' : ''));
push('chartId', chart.chartId);
push('viz', chart.vizType);
push('datasource', chart.datasourceName);
push('datasourceId', chart.datasourceId);
}
const dashboard = ctx.dashboard as
| { dashboardId?: number; title?: string; filters?: Array<{ label: string; value: unknown }> }
| undefined;
if (dashboard) {
push('dashboard', dashboard.title);
push('dashboardId', dashboard.dashboardId);
const filters = dashboard.filters ?? [];
if (filters.length) {
push(
'filters',
filters.map(f => `${f.label}=${JSON.stringify(f.value)}`).join(', '),
);
}
}
const dataset = ctx.dataset as
| {
datasetId?: number;
datasetName?: string;
schema?: string | null;
catalog?: string | null;
databaseName?: string | null;
isVirtual?: boolean;
}
| undefined;
if (dataset) {
push('dataset', dataset.datasetName);
push('datasetId', dataset.datasetId);
push('schema', dataset.schema);
push('catalog', dataset.catalog);
push('database', dataset.databaseName);
if (typeof dataset.isVirtual === 'boolean') {
push('virtual', dataset.isVirtual);
}
}
if (ctx.sqlLab) {
push('tab', ctx.sqlLab.title);
}
return rows;
};
export const Panel: React.FC<Props> = ({ onClose }) => {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [pageContext, setPageContext] = useState<PageContext>(() => getPageContext());
const controllerRef = useRef<AbortController | null>(null);
useEffect(
() => subscribeToPageChanges(() => setPageContext(getPageContext())),
[],
);
useEffect(
() => () => {
// Component unmount cancels any in-flight stream.
controllerRef.current?.abort();
},
[],
);
const send = useCallback(async () => {
const prompt = input.trim();
if (!prompt || streaming) return;
setInput('');
const userMsg: Message = { id: ++messageSeq, from: 'user', text: prompt };
const botMsg: Message = { id: ++messageSeq, from: 'bot', text: '' };
setMessages(prev => [...prev, userMsg, botMsg]);
setStreaming(true);
const controller = new AbortController();
controllerRef.current = controller;
registerActiveController(controller);
try {
for await (const token of streamReply(prompt, controller.signal)) {
setMessages(prev =>
prev.map(m => (m.id === botMsg.id ? { ...m, text: m.text + token } : m)),
);
}
} finally {
unregisterActiveController(controller);
controllerRef.current = null;
setStreaming(false);
}
}, [input, streaming]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
}, []);
return (
<div
data-test="reference-chatbot-panel"
style={{
width: 360,
maxHeight: 480,
display: 'flex',
flexDirection: 'column',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
overflow: 'hidden',
fontSize: 13,
}}
>
<header
style={{
padding: '8px 12px',
background: '#1f6feb',
color: '#fff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Reference Chatbot</span>
<button
type="button"
onClick={onClose}
aria-label="Close chatbot"
data-test="reference-chatbot-close"
style={{
background: 'transparent',
border: 'none',
color: '#fff',
fontSize: 16,
cursor: 'pointer',
}}
>
×
</button>
</header>
<div
data-test="reference-chatbot-context"
style={{
padding: '6px 12px',
background: '#f6f8fa',
borderBottom: '1px solid #eaecef',
fontFamily: 'monospace',
fontSize: 11,
color: '#57606a',
wordBreak: 'break-all',
}}
>
<div>page: {pageContext.pageType}</div>
{contextRows(pageContext).map(([label, value]) => (
<div key={label}>
{label}: {value}
</div>
))}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{messages.length === 0 && (
<p style={{ color: '#8c8c8c' }}>
Ask anything replies are canned tokens streamed by the reference extension.
</p>
)}
{messages.map(m => (
<div
key={m.id}
data-test={`reference-chatbot-msg-${m.from}`}
style={{
margin: '6px 0',
textAlign: m.from === 'user' ? 'right' : 'left',
}}
>
<span
style={{
display: 'inline-block',
padding: '4px 8px',
borderRadius: 6,
background: m.from === 'user' ? '#1f6feb' : '#eef0f3',
color: m.from === 'user' ? '#fff' : '#1f2328',
maxWidth: '85%',
whiteSpace: 'pre-wrap',
}}
>
{m.text || '…'}
</span>
</div>
))}
</div>
<footer
style={{
padding: 8,
borderTop: '1px solid #eaecef',
display: 'flex',
gap: 6,
}}
>
<input
aria-label="Chat input"
data-test="reference-chatbot-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder="Type a message"
style={{
flex: 1,
padding: '4px 8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
/>
{streaming ? (
<button
type="button"
onClick={cancel}
data-test="reference-chatbot-cancel"
style={{ padding: '4px 10px' }}
>
Stop
</button>
) : (
<button
type="button"
onClick={send}
data-test="reference-chatbot-send"
disabled={!input.trim()}
style={{ padding: '4px 10px' }}
>
Send
</button>
)}
</footer>
</div>
);
};

View File

@@ -0,0 +1,159 @@
/**
* 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.
*/
/**
* Single integration seam for the P3 namespaces.
*
* Each surface namespace is consumed via a try/catch — the host may ship a
* version where a namespace function is declared but not yet implemented at
* runtime, and the reference extension must keep working in that case. As
* each namespace lights up on the host, that branch starts returning real
* data without any change here.
*
* Route inference is the fallback when navigation.getPageType() is absent.
*/
import * as core from '@apache-superset/core';
export type PageType =
| 'home'
| 'dashboard'
| 'dashboard_list'
| 'chart'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'unknown';
export interface PageContext {
pageType: PageType;
dashboard?: unknown;
chart?: unknown;
dataset?: unknown;
sqlLab?: { tabId: string; title: string };
href: string;
}
const tryCall = <T>(fn: () => T | undefined): T | undefined => {
try {
return fn();
} catch {
return undefined;
}
};
const inferPageType = (pathname: string): PageType => {
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname.startsWith('/sqllab')) return 'sqllab';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (
pathname.startsWith('/superset/dashboard') ||
pathname.startsWith('/dashboard')
)
return 'dashboard';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/explore') || pathname.startsWith('/chart'))
return 'chart';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/tablemodelview') || pathname.startsWith('/dataset'))
return 'dataset';
if (pathname === '/' || pathname.startsWith('/superset/welcome'))
return 'home';
return 'unknown';
};
const readSqlLabTab = (): PageContext['sqlLab'] => {
const tab = tryCall(() => (core as any).sqlLab?.getCurrentTab?.());
return tab ? { tabId: tab.id, title: tab.title } : undefined;
};
const readPageType = (pathname: string): PageType => {
const fromNav = tryCall(() => (core as any).navigation?.getPageType?.());
return (fromNav as PageType | undefined) ?? inferPageType(pathname);
};
/**
* Subscribe to page-context changes and invoke `onChange` whenever any part of
* the context may have changed. Returns a cleanup function.
*
* Three classes of change are watched:
* - Navigation (`navigation.onDidChangePage`, or `popstate` as a fallback for
* hosts without the namespace) — the user moved to a different surface.
* - Entity hydration (`explore.onDidChangeChart`, `dashboard.onDidChangeDashboard`,
* `dataset.onDidChangeDataset`) — the surface's entity loaded or changed
* *after* navigation settled. This matters because a surface (notably Explore)
* can finish hydrating several seconds after the route change fires, so a
* navigation-only subscription would read empty entity context and never
* refresh once the real data arrives.
* - In-surface SQL Lab changes (`sqlLab.onDidChangeActiveTab`,
* `sqlLab.onDidChangeTabTitle`) — switching or renaming a tab does not change
* the route, so without these the panel would keep showing the first tab.
*/
export const subscribeToPageChanges = (onChange: () => void): (() => void) => {
const disposers: Array<() => void> = [];
const nav = tryCall(() => (core as any).navigation);
if (nav?.onDidChangePage) {
const sub = nav.onDidChangePage(onChange);
disposers.push(() => sub.dispose());
} else {
window.addEventListener('popstate', onChange);
disposers.push(() => window.removeEventListener('popstate', onChange));
}
// Entity-context change events. Each is optional — a host may not implement a
// given namespace yet — so subscribe defensively and collect any disposer.
const subscribeEntity = (
getNamespace: () => any,
method: string,
): void => {
const sub = tryCall(() => getNamespace()?.[method]?.(onChange));
if (sub?.dispose) {
disposers.push(() => sub.dispose());
}
};
subscribeEntity(() => (core as any).explore, 'onDidChangeChart');
subscribeEntity(() => (core as any).dashboard, 'onDidChangeDashboard');
subscribeEntity(() => (core as any).dataset, 'onDidChangeDataset');
// SQL Lab tab switches/renames happen without a route change.
subscribeEntity(() => (core as any).sqlLab, 'onDidChangeActiveTab');
subscribeEntity(() => (core as any).sqlLab, 'onDidChangeTabTitle');
return () => disposers.forEach(dispose => dispose());
};
export const getPageContext = (): PageContext => {
const { pathname, href } =
typeof window !== 'undefined'
? window.location
: { pathname: '', href: '' };
return {
pageType: readPageType(pathname),
dashboard: tryCall(() => (core as any).dashboard?.getCurrentDashboard?.()),
chart: tryCall(() => (core as any).explore?.getCurrentChart?.()),
dataset: tryCall(() => (core as any).dataset?.getCurrentDataset?.()),
sqlLab: readSqlLabTab(),
href,
};
};

View File

@@ -0,0 +1,30 @@
/**
* 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.
*/
/**
* Module Federation entry. The host loads `./index` and invokes the factory;
* the side effect below registers the view + commands. The host's loader
* intercepts registerView calls to collect disposables for deactivation, so
* returning the master Disposable here is also captured by the test harness
* for direct assertion.
*/
import { activate } from './activate';
export const disposable = activate();

View File

@@ -0,0 +1,57 @@
/**
* 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.
*/
/**
* Module-scoped open/closed state plus a tiny emitter the UI subscribes to.
*
* Lives entirely inside the extension — never reaches into the host store.
* Reset on dispose so re-activation starts cleanly.
*/
export type OpenStateListener = (open: boolean) => void;
let open = false;
const listeners = new Set<OpenStateListener>();
export const isOpen = (): boolean => open;
export const setOpen = (next: boolean): void => {
if (next === open) return;
open = next;
listeners.forEach(fn => {
try {
fn(open);
} catch {
// A listener throwing must not block other listeners or flip our state back.
}
});
};
export const subscribe = (fn: OpenStateListener): (() => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
/** Drains listeners and resets state. Called from the master Disposable. */
export const resetState = (): void => {
open = false;
listeners.clear();
};

View File

@@ -0,0 +1,73 @@
/**
* 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.
*/
/**
* Mock streaming reply used to validate stream teardown semantics.
*
* The reference chatbot is environment-validation only — there is no LLM.
* This iterator yields canned tokens on a timer and exits cleanly when its
* AbortSignal is fired. Disposal of the extension aborts any in-flight
* controller, which is the contract that proves async cancellation works.
*/
const TICK_MS = 40;
const buildReply = (prompt: string): string => {
const trimmed = prompt.trim();
if (!trimmed) {
return 'Reference chatbot online. Send a message to validate streaming.';
}
return (
`[reference-chatbot] received "${trimmed}". ` +
'Streaming token-by-token to validate cancellation and teardown.'
);
};
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
export async function* streamReply(
prompt: string,
signal: AbortSignal,
): AsyncIterableIterator<string> {
const tokens = buildReply(prompt).split(/(\s+)/);
for (const token of tokens) {
if (signal.aborted) return;
try {
await sleep(TICK_MS, signal);
} catch {
return;
}
yield token;
}
}

View File

@@ -0,0 +1,46 @@
/**
* 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.
*/
/**
* Module-scoped registry of in-flight stream AbortControllers.
*
* Lets the master Disposable abort any running stream even when the panel
* is unmounted by a route change or by re-activation of the extension.
*/
const active = new Set<AbortController>();
export const registerActiveController = (c: AbortController): void => {
active.add(c);
};
export const unregisterActiveController = (c: AbortController): void => {
active.delete(c);
};
export const abortAllActiveControllers = (): void => {
active.forEach(c => {
try {
c.abort();
} catch {
// ignore — abort() should not throw, but stay defensive.
}
});
active.clear();
};

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2019",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "es2019"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/__tests__"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@apache-superset/core": ["src/__tests__/sdkMock.ts"]
},
"typeRoots": [
"./node_modules/@types",
"../../superset-frontend/node_modules/@types"
],
"types": ["jest", "node"]
},
"include": ["src"],
"exclude": []
}

View File

@@ -0,0 +1,108 @@
/**
* 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.
*/
const path = require('path');
const fs = require('fs');
const { ModuleFederationPlugin } = require('webpack').container;
const packageConfig = require('./package.json');
const extensionConfig = require('./extension.json');
const MODULE_FEDERATION_NAME = 'apacheSuperset_referenceChatbot';
/**
* Emits the `manifest.json` the host reads from the extension `dist/` root.
*
* The host (`superset/extensions/utils.py`) expects an extension dist laid out
* as `dist/manifest.json` plus the federated bundle under `dist/frontend/dist/`.
* The manifest carries `extension.json` verbatim, plus the composite `id` and a
* `frontend` block naming the content-hashed `remoteEntry` so the host can load
* the right file. Because the hash is only known after the build, the manifest
* is written from the final asset names rather than checked in.
*/
class EmitManifestPlugin {
apply(compiler) {
compiler.hooks.afterEmit.tap('EmitManifestPlugin', compilation => {
const assets = Object.keys(compilation.assets);
const remoteEntry = assets.find(name => /^remoteEntry\..*\.js$/.test(name));
if (!remoteEntry) {
throw new Error('EmitManifestPlugin: no remoteEntry asset was emitted');
}
const manifest = {
...extensionConfig,
id: `${extensionConfig.publisher}.${extensionConfig.name}`,
frontend: {
remoteEntry,
moduleFederationName: MODULE_FEDERATION_NAME,
},
};
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
);
});
}
}
module.exports = (env, argv) => {
const isProd = argv.mode === 'production';
return {
entry: isProd ? {} : './src/index.tsx',
mode: isProd ? 'production' : 'development',
devtool: isProd ? false : 'eval-cheap-module-source-map',
devServer: {
port: 3030,
headers: { 'Access-Control-Allow-Origin': '*' },
},
output: {
clean: true,
filename: isProd ? undefined : '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist', 'frontend', 'dist'),
publicPath: `/api/v1/extensions/${extensionConfig.publisher}/${extensionConfig.name}/`,
},
resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
externalsType: 'window',
externals: { '@apache-superset/core': 'superset' },
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
],
},
plugins: [
new ModuleFederationPlugin({
name: MODULE_FEDERATION_NAME,
filename: 'remoteEntry.[contenthash].js',
exposes: { './index': './src/index.tsx' },
shared: {
react: {
singleton: true,
requiredVersion: packageConfig.peerDependencies.react,
import: false,
},
'react-dom': {
singleton: true,
requiredVersion: packageConfig.peerDependencies['react-dom'],
import: false,
},
},
}),
new EmitManifestPlugin(),
],
};
};

138
extensions/chat2/README.md Normal file
View File

@@ -0,0 +1,138 @@
<!--
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.
-->
# Reference Chatbot Extension
Canonical environment-validation extension for the `superset.chatbot`
contribution area. **Not** a product chatbot — there is no LLM, no backend,
no persistence. Its purpose is to exercise the extension platform end-to-end:
- `views.registerView` at `superset.chatbot` (singleton resolution)
- Lifecycle activation + a master disposable that tears down everything
- `commands.registerCommand` for `core.chatbot__open|close|toggle`
- Mock streaming with `AbortController` cancellation on dispose
- Defense-in-depth React error boundary inside the panel
- A single P3 page-context seam that lights up automatically as the
`dashboard` / `explore` / `dataset` / `navigation` namespaces become
available at runtime on the host
It is intended as the reference implementation third-party chatbot extension
authors copy. Anything that ships as host-internal (the mount point, the
admin picker, the `getActiveChatbot` resolver) is **not** here — see the
host side at `superset-frontend/src/components/ChatbotMount/` and
`superset-frontend/src/core/chatbot/`.
## Layout
```
extensions/chat/
├── extension.json Manifest (app.chatbot view + commands)
├── package.json
├── tsconfig.json
├── webpack.config.js ModuleFederation → window.superset
├── jest.config.js Self-contained unit tests
└── src/
├── index.tsx MF entry — calls activate() once
├── activate.ts Returns master disposable
├── commands.ts core.chatbot__open|close|toggle
├── state.ts Module-scoped open/closed + emitter
├── ReferenceChatbot.tsx Root component (bubble ↔ panel)
├── components/
│ ├── Bubble.tsx
│ ├── Panel.tsx
│ └── ErrorBoundary.tsx
├── streaming/
│ ├── mockStream.ts AsyncIterable<string> + AbortSignal
│ └── registry.ts Cross-component abort tracking
├── context/
│ └── pageContext.ts P3 namespace seam (defensive)
└── __tests__/
├── sdkMock.ts In-memory @apache-superset/core mock
└── activate.test.tsx
```
## Run the unit tests
```bash
cd extensions/chat
npm install # first time only
npx jest
```
The tests mock `@apache-superset/core` via `src/__tests__/sdkMock.ts` so they
do not depend on host runtime wiring.
## Build / bundle for deployment
```bash
# from the extension folder
npm install
npm run build
# packaging into a .supx is handled by the Superset extensions CLI
pip install apache-superset-extensions-cli
superset-extensions bundle # produces apache-superset.reference-chatbot-0.1.0.supx
```
Drop the `.supx` into the `EXTENSIONS_PATH` of a Superset instance that has
`FEATURE_FLAGS = { "ENABLE_EXTENSIONS": True }`.
## Selecting it as the active chatbot
The host's singleton picker reads `active_chatbot_id` from the admin
settings endpoint (`/api/v1/extensions/settings`). Set it to:
```
apache-superset.reference-chatbot
```
If no admin selection exists, the host falls back to the first-to-register
chatbot — installing this extension alone is enough for the bubble to appear.
## P3 integration seams
All page-context derivation lives in [`src/context/pageContext.ts`](src/context/pageContext.ts).
Each namespace branch (`dashboard`, `explore`, `dataset`, `navigation`) is
called defensively — when the host implementation lands, the returned value
becomes non-undefined automatically with no other change in the extension.
The panel re-reads context on `popstate`. Once `navigation.onDidChangePage`
is live on the host, the panel's `useEffect` should subscribe to it instead;
that is the only file in the extension that needs to change for full P3
context sync.
## Known intentional non-features
- No conversation persistence — by design (extension scope per SIP §2).
- No real network. The mock stream is a `setTimeout` token emitter so the
cancellation contract is exercised without external dependencies.
- No keyboard shortcut binding (Cmd+K). Extensions own that, but it adds
surface area not needed for platform validation.
- No notification badge / icon mutation. SIP §3.2 recommends static icons;
the bubble re-renders freely already.
## TODOs
- **P1**: if/when the host gains `deactivate(): Promise<void>`, wrap the
master disposer in `activate.ts` to flush async work before returning.
- **P3**: replace the `popstate` listener in `Panel.tsx` with
`navigation.onDidChangePage` once that event is wired up host-side.
- **P4**: if the host pre-registers `core.chatbot__*` as host-owned intents,
swap `commands.registerCommand` for the implementation hook in
`commands.ts`. Command IDs do not change.

View File

@@ -0,0 +1,23 @@
{
"publisher": "apache-superset",
"name": "alt-chatbot",
"displayName": "Alt Chatbot",
"description": "Second chatbot for testing multi-chatbot selection in the superset.chatbot contribution area.",
"version": "0.1.0",
"license": "Apache-2.0",
"permissions": [],
"contributes": {
"views": {
"app": {
"chatbot": [
{
"id": "apache-superset.alt-chatbot",
"name": "Alt Chatbot",
"description": "Second chatbot for testing singleton resolution.",
"icon": "Star"
}
]
}
}
}
}

View File

@@ -0,0 +1,53 @@
/**
* 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.
*/
const path = require('path');
// When run as a standalone package (`cd extensions/chat && npm test`), modules
// resolve from this folder's own node_modules. When run from the superset-frontend
// workspace (CI, dev convenience), resolve ts-jest there too.
const tsJest = (() => {
try {
require.resolve('ts-jest');
return 'ts-jest';
} catch {
return path.resolve(
__dirname,
'..',
'..',
'superset-frontend',
'node_modules',
'ts-jest',
);
}
})();
module.exports = {
testEnvironment: 'jsdom',
rootDir: __dirname,
testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
// When running from the extension folder without node_modules installed,
// resolve react / react-dom from the superset-frontend workspace.
modulePaths: [path.resolve(__dirname, '..', '..', 'superset-frontend', 'node_modules')],
moduleNameMapper: {
'^@apache-superset/core$': '<rootDir>/src/__tests__/sdkMock.ts',
},
transform: {
'^.+\\.tsx?$': [tsJest, { tsconfig: '<rootDir>/tsconfig.test.json' }],
},
};

View File

@@ -0,0 +1,26 @@
{
"name": "@apache-superset/alt-chatbot",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"description": "Second chatbot extension for testing multi-chatbot selection in the Superset chatbot contribution area.",
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --stats-error-details --mode production"
},
"peerDependencies": {
"@apache-superset/core": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@apache-superset/core": "^0.1.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"ts-loader": "^9.5.0",
"typescript": "^5.0.0",
"webpack": "^5.0.0",
"webpack-cli": "^5.0.0",
"webpack-dev-server": "^5.0.0"
}
}

View File

@@ -0,0 +1,46 @@
/**
* 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 React, { useEffect, useState } from 'react';
import { Bubble } from './components/Bubble';
import { Panel } from './components/Panel';
import { ExtensionErrorBoundary } from './components/ErrorBoundary';
import { isOpen, setOpen, subscribe } from './state';
/**
* Root extension component. Mirrors module-state into React via `subscribe`.
*
* Unlike the Reference Chatbot, Alt registers no `core.chatbot__*` commands
* (those ids are globally owned by Reference), so the bubble↔panel transition
* drives the local open-state directly via `setOpen`.
*/
export const ReferenceChatbot: React.FC = () => {
const [open, setOpenState] = useState<boolean>(isOpen());
useEffect(() => subscribe(setOpenState), []);
return (
<ExtensionErrorBoundary>
{open ? (
<Panel onClose={() => setOpen(false)} />
) : (
<Bubble onClick={() => setOpen(true)} />
)}
</ExtensionErrorBoundary>
);
};

View File

@@ -0,0 +1,131 @@
/**
* 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 { registry, reset } from './sdkMock';
import { activate, VIEW_ID, CHATBOT_LOCATION } from '../activate';
import { isOpen, setOpen } from '../state';
import { streamReply } from '../streaming/mockStream';
import {
registerActiveController,
unregisterActiveController,
abortAllActiveControllers,
} from '../streaming/registry';
beforeEach(() => {
reset();
});
test('registers one view at superset.chatbot and no commands', () => {
const disposable = activate();
try {
expect(registry.views.size).toBe(1);
const entry = registry.views.get(VIEW_ID);
expect(entry?.location).toBe(CHATBOT_LOCATION);
expect(entry?.view.icon).toBe('Star');
// Alt Chatbot is view-only — the core.chatbot__* command ids are owned by
// the Reference Chatbot, so Alt registers none of its own.
expect(registry.commands.size).toBe(0);
} finally {
disposable.dispose();
}
});
test('setOpen drives open/close through module state', () => {
const disposable = activate();
try {
expect(isOpen()).toBe(false);
setOpen(true);
expect(isOpen()).toBe(true);
setOpen(false);
expect(isOpen()).toBe(false);
} finally {
disposable.dispose();
}
});
test('disposing the master disposable unregisters the view', () => {
const disposable = activate();
expect(registry.views.size).toBe(1);
disposable.dispose();
expect(registry.views.size).toBe(0);
expect(registry.commands.size).toBe(0);
});
test('disposal is idempotent', () => {
const disposable = activate();
disposable.dispose();
expect(() => disposable.dispose()).not.toThrow();
expect(registry.views.size).toBe(0);
});
test('re-activate after dispose works (validates replace semantics)', () => {
const first = activate();
first.dispose();
const second = activate();
try {
expect(registry.views.size).toBe(1);
expect(isOpen()).toBe(false); // resetState() cleared open flag
} finally {
second.dispose();
}
});
test('aborting an active controller stops the stream cleanly', async () => {
const controller = new AbortController();
registerActiveController(controller);
const iter = streamReply('hello world', controller.signal);
const received: string[] = [];
const consume = (async () => {
for await (const tok of iter) received.push(tok);
})();
// Abort after a single tick — the iterator must return without throwing.
await new Promise(r => setTimeout(r, 50));
abortAllActiveControllers();
await expect(consume).resolves.toBeUndefined();
unregisterActiveController(controller);
expect(received.length).toBeLessThan(20); // would be ~20+ tokens if uncancelled
});
test('disposing the extension aborts any in-flight controller', async () => {
const disposable = activate();
const controller = new AbortController();
registerActiveController(controller);
const iter = streamReply('a longer prompt to ensure many tokens', controller.signal);
const consume = (async () => {
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
for await (const _tok of iter) {
// drain
}
})();
await new Promise(r => setTimeout(r, 30));
disposable.dispose();
await expect(consume).resolves.toBeUndefined();
expect(controller.signal.aborted).toBe(true);
});

View File

@@ -0,0 +1,119 @@
/**
* 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.
*/
/**
* In-memory mock of `@apache-superset/core` for unit-testing the extension.
*
* Mirrors only the surfaces the reference chatbot consumes:
* - views.registerView returns a disposable that removes the view
* - commands.registerCommand / executeCommand round-trip handlers
* - sqlLab.getCurrentTab returns undefined (no SQL Lab in tests)
*
* The mock is intentionally observable: tests can read `registry.views` and
* `registry.commands` to assert contract compliance.
*/
import type { ReactElement } from 'react';
type Provider = () => ReactElement;
interface ViewDescriptor {
id: string;
name: string;
icon?: string;
description?: string;
}
interface DisposableLike {
dispose(): void;
}
interface RegisteredView {
view: ViewDescriptor;
location: string;
provider: Provider;
}
interface RegisteredCommand {
id: string;
title: string;
handler: (...args: any[]) => any;
}
export const registry = {
views: new Map<string, RegisteredView>(),
commands: new Map<string, RegisteredCommand>(),
};
export const reset = (): void => {
registry.views.clear();
registry.commands.clear();
};
export const views = {
registerView(
view: ViewDescriptor,
location: string,
provider: Provider,
): DisposableLike {
registry.views.set(view.id, { view, location, provider });
return {
dispose: () => {
registry.views.delete(view.id);
},
};
},
getViews(location: string) {
return Array.from(registry.views.values())
.filter(v => v.location === location)
.map(v => v.view);
},
};
export const commands = {
registerCommand(
command: { id: string; title: string },
handler: (...args: any[]) => any,
): DisposableLike {
registry.commands.set(command.id, {
id: command.id,
title: command.title,
handler,
});
return {
dispose: () => {
registry.commands.delete(command.id);
},
};
},
async executeCommand(id: string, ...rest: any[]): Promise<unknown> {
const cmd = registry.commands.get(id);
return cmd?.handler(...rest);
},
getCommands() {
return Array.from(registry.commands.values()).map(c => ({
id: c.id,
title: c.title,
}));
},
};
export const sqlLab = {
getCurrentTab: () => undefined,
};

View File

@@ -0,0 +1,83 @@
/**
* 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 React from 'react';
import { views } from '@apache-superset/core';
import { ReferenceChatbot } from './ReferenceChatbot';
import { abortAllActiveControllers } from './streaming/registry';
import { resetState } from './state';
export const VIEW_ID = 'apache-superset.alt-chatbot';
export const CHATBOT_LOCATION = 'superset.chatbot';
interface DisposableLike {
dispose(): void;
}
/**
* Registers the reference chatbot and returns a single disposable that
* tears down everything it created. Idempotent across activate/dispose cycles.
*
* Cleanup order matters: stop in-flight streams first so listeners do not
* receive late tokens, then unregister commands (so user clicks during teardown
* become no-ops), then unregister the view (so the host's ChatbotMount unmounts
* the React tree), and finally reset module state.
*
* Returns a plain `{ dispose }` object rather than constructing a Disposable
* from the SDK — the SDK class is host-injected and only reliably available
* via window.superset at runtime, while plain disposable-likes work in both
* runtime and unit-test contexts.
*
* TODO(P1): when the host gains an async `deactivate(): Promise<void>` hook,
* wrap the master disposer to flush in-flight async work before returning.
*/
export const activate = (): DisposableLike => {
// Alt Chatbot deliberately registers no commands: the `core.chatbot__*`
// command ids are owned by the Reference Chatbot, and command ids are global,
// so a second registrant would collide. Alt is a view-only chatbot used to
// exercise multi-chatbot selection.
const viewDisposable = views.registerView(
{
id: VIEW_ID,
name: 'Alt Chatbot',
icon: 'Star',
description: 'Second chatbot for testing singleton resolution.',
},
CHATBOT_LOCATION,
() => React.createElement(ReferenceChatbot),
);
let disposed = false;
return {
dispose() {
if (disposed) return;
disposed = true;
try {
abortAllActiveControllers();
} catch {
// streams are best-effort during teardown
}
try {
viewDisposable.dispose();
} catch {
// ignore
}
resetState();
},
};
};

View File

@@ -0,0 +1,46 @@
/**
* 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 React from 'react';
interface Props {
onClick: () => void;
}
export const Bubble: React.FC<Props> = ({ onClick }) => (
<button
type="button"
onClick={onClick}
data-test="reference-chatbot-bubble"
aria-label="Open Alt chatbot"
style={{
width: 56,
height: 56,
borderRadius: '50%',
border: 'none',
background: '#2da44e',
color: '#fff',
fontSize: 24,
fontWeight: 600,
cursor: 'pointer',
boxShadow: '0 4px 14px rgba(0,0,0,0.18)',
}}
>
?
</button>
);

View File

@@ -0,0 +1,66 @@
/**
* 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 React from 'react';
interface State {
error: Error | null;
}
/**
* Defense-in-depth boundary. The host already wraps the mount in its own
* ErrorBoundary; this one keeps a panel crash from also bringing down the
* bubble next to it.
*/
export class ExtensionErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
State
> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error): void {
// eslint-disable-next-line no-console
console.error('[reference-chatbot] render error', error);
}
render() {
if (this.state.error) {
return (
<div
data-test="reference-chatbot-error"
style={{
padding: 12,
border: '1px solid #f5222d',
borderRadius: 6,
background: '#fff1f0',
color: '#a8071a',
fontSize: 12,
maxWidth: 320,
}}
>
Reference chatbot crashed: {this.state.error.message}
</div>
);
}
return <>{this.props.children}</>;
}
}

View File

@@ -0,0 +1,307 @@
/**
* 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamReply } from '../streaming/mockStream';
import { getPageContext, PageContext, subscribeToPageChanges } from '../context/pageContext';
import { registerActiveController, unregisterActiveController } from '../streaming/registry';
interface Props {
onClose: () => void;
}
interface Message {
id: number;
from: 'user' | 'bot';
text: string;
}
let messageSeq = 0;
/**
* Builds the full set of context fields the host exposes for the current
* surface, as ordered [label, value] rows. Whatever the host provides for where
* the user is, the panel shows — nothing is summarized away. Returns an empty
* array for surfaces with no active entity (list/home pages), where the
* `page:` line alone is the context.
*/
const contextRows = (ctx: PageContext): Array<[string, string]> => {
const rows: Array<[string, string]> = [];
const push = (label: string, value: unknown) => {
if (value !== undefined && value !== null && value !== '') {
rows.push([label, String(value)]);
}
};
const chart = ctx.chart as
| {
chartId?: number | null;
chartName?: string | null;
vizType?: string;
datasourceId?: number | null;
datasourceName?: string | null;
}
| undefined;
if (chart) {
push('chart', chart.chartName ?? (chart.chartId == null ? '(unsaved)' : ''));
push('chartId', chart.chartId);
push('viz', chart.vizType);
push('datasource', chart.datasourceName);
push('datasourceId', chart.datasourceId);
}
const dashboard = ctx.dashboard as
| { dashboardId?: number; title?: string; filters?: Array<{ label: string; value: unknown }> }
| undefined;
if (dashboard) {
push('dashboard', dashboard.title);
push('dashboardId', dashboard.dashboardId);
const filters = dashboard.filters ?? [];
if (filters.length) {
push(
'filters',
filters.map(f => `${f.label}=${JSON.stringify(f.value)}`).join(', '),
);
}
}
const dataset = ctx.dataset as
| {
datasetId?: number;
datasetName?: string;
schema?: string | null;
catalog?: string | null;
databaseName?: string | null;
isVirtual?: boolean;
}
| undefined;
if (dataset) {
push('dataset', dataset.datasetName);
push('datasetId', dataset.datasetId);
push('schema', dataset.schema);
push('catalog', dataset.catalog);
push('database', dataset.databaseName);
if (typeof dataset.isVirtual === 'boolean') {
push('virtual', dataset.isVirtual);
}
}
if (ctx.sqlLab) {
push('tab', ctx.sqlLab.title);
}
return rows;
};
export const Panel: React.FC<Props> = ({ onClose }) => {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [streaming, setStreaming] = useState(false);
const [pageContext, setPageContext] = useState<PageContext>(() => getPageContext());
const controllerRef = useRef<AbortController | null>(null);
useEffect(
() => subscribeToPageChanges(() => setPageContext(getPageContext())),
[],
);
useEffect(
() => () => {
// Component unmount cancels any in-flight stream.
controllerRef.current?.abort();
},
[],
);
const send = useCallback(async () => {
const prompt = input.trim();
if (!prompt || streaming) return;
setInput('');
const userMsg: Message = { id: ++messageSeq, from: 'user', text: prompt };
const botMsg: Message = { id: ++messageSeq, from: 'bot', text: '' };
setMessages(prev => [...prev, userMsg, botMsg]);
setStreaming(true);
const controller = new AbortController();
controllerRef.current = controller;
registerActiveController(controller);
try {
for await (const token of streamReply(prompt, controller.signal)) {
setMessages(prev =>
prev.map(m => (m.id === botMsg.id ? { ...m, text: m.text + token } : m)),
);
}
} finally {
unregisterActiveController(controller);
controllerRef.current = null;
setStreaming(false);
}
}, [input, streaming]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
}, []);
return (
<div
data-test="reference-chatbot-panel"
style={{
width: 360,
maxHeight: 480,
display: 'flex',
flexDirection: 'column',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
overflow: 'hidden',
fontSize: 13,
}}
>
<header
style={{
padding: '8px 12px',
background: '#2da44e',
color: '#fff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Alt Chatbot</span>
<button
type="button"
onClick={onClose}
aria-label="Close chatbot"
data-test="reference-chatbot-close"
style={{
background: 'transparent',
border: 'none',
color: '#fff',
fontSize: 16,
cursor: 'pointer',
}}
>
×
</button>
</header>
<div
data-test="reference-chatbot-context"
style={{
padding: '6px 12px',
background: '#f6f8fa',
borderBottom: '1px solid #eaecef',
fontFamily: 'monospace',
fontSize: 11,
color: '#57606a',
wordBreak: 'break-all',
}}
>
<div>page: {pageContext.pageType}</div>
{contextRows(pageContext).map(([label, value]) => (
<div key={label}>
{label}: {value}
</div>
))}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{messages.length === 0 && (
<p style={{ color: '#8c8c8c' }}>
Ask anything replies are canned tokens streamed by the Alt Chatbot extension.
</p>
)}
{messages.map(m => (
<div
key={m.id}
data-test={`reference-chatbot-msg-${m.from}`}
style={{
margin: '6px 0',
textAlign: m.from === 'user' ? 'right' : 'left',
}}
>
<span
style={{
display: 'inline-block',
padding: '4px 8px',
borderRadius: 6,
background: m.from === 'user' ? '#2da44e' : '#eef0f3',
color: m.from === 'user' ? '#fff' : '#1f2328',
maxWidth: '85%',
whiteSpace: 'pre-wrap',
}}
>
{m.text || '…'}
</span>
</div>
))}
</div>
<footer
style={{
padding: 8,
borderTop: '1px solid #eaecef',
display: 'flex',
gap: 6,
}}
>
<input
aria-label="Chat input"
data-test="reference-chatbot-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder="Type a message"
style={{
flex: 1,
padding: '4px 8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
/>
{streaming ? (
<button
type="button"
onClick={cancel}
data-test="reference-chatbot-cancel"
style={{ padding: '4px 10px' }}
>
Stop
</button>
) : (
<button
type="button"
onClick={send}
data-test="reference-chatbot-send"
disabled={!input.trim()}
style={{ padding: '4px 10px' }}
>
Send
</button>
)}
</footer>
</div>
);
};

View File

@@ -0,0 +1,159 @@
/**
* 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.
*/
/**
* Single integration seam for the P3 namespaces.
*
* Each surface namespace is consumed via a try/catch — the host may ship a
* version where a namespace function is declared but not yet implemented at
* runtime, and the reference extension must keep working in that case. As
* each namespace lights up on the host, that branch starts returning real
* data without any change here.
*
* Route inference is the fallback when navigation.getPageType() is absent.
*/
import * as core from '@apache-superset/core';
export type PageType =
| 'home'
| 'dashboard'
| 'dashboard_list'
| 'chart'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'unknown';
export interface PageContext {
pageType: PageType;
dashboard?: unknown;
chart?: unknown;
dataset?: unknown;
sqlLab?: { tabId: string; title: string };
href: string;
}
const tryCall = <T>(fn: () => T | undefined): T | undefined => {
try {
return fn();
} catch {
return undefined;
}
};
const inferPageType = (pathname: string): PageType => {
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname.startsWith('/sqllab')) return 'sqllab';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (
pathname.startsWith('/superset/dashboard') ||
pathname.startsWith('/dashboard')
)
return 'dashboard';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/explore') || pathname.startsWith('/chart'))
return 'chart';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/tablemodelview') || pathname.startsWith('/dataset'))
return 'dataset';
if (pathname === '/' || pathname.startsWith('/superset/welcome'))
return 'home';
return 'unknown';
};
const readSqlLabTab = (): PageContext['sqlLab'] => {
const tab = tryCall(() => (core as any).sqlLab?.getCurrentTab?.());
return tab ? { tabId: tab.id, title: tab.title } : undefined;
};
const readPageType = (pathname: string): PageType => {
const fromNav = tryCall(() => (core as any).navigation?.getPageType?.());
return (fromNav as PageType | undefined) ?? inferPageType(pathname);
};
/**
* Subscribe to page-context changes and invoke `onChange` whenever any part of
* the context may have changed. Returns a cleanup function.
*
* Three classes of change are watched:
* - Navigation (`navigation.onDidChangePage`, or `popstate` as a fallback for
* hosts without the namespace) — the user moved to a different surface.
* - Entity hydration (`explore.onDidChangeChart`, `dashboard.onDidChangeDashboard`,
* `dataset.onDidChangeDataset`) — the surface's entity loaded or changed
* *after* navigation settled. This matters because a surface (notably Explore)
* can finish hydrating several seconds after the route change fires, so a
* navigation-only subscription would read empty entity context and never
* refresh once the real data arrives.
* - In-surface SQL Lab changes (`sqlLab.onDidChangeActiveTab`,
* `sqlLab.onDidChangeTabTitle`) — switching or renaming a tab does not change
* the route, so without these the panel would keep showing the first tab.
*/
export const subscribeToPageChanges = (onChange: () => void): (() => void) => {
const disposers: Array<() => void> = [];
const nav = tryCall(() => (core as any).navigation);
if (nav?.onDidChangePage) {
const sub = nav.onDidChangePage(onChange);
disposers.push(() => sub.dispose());
} else {
window.addEventListener('popstate', onChange);
disposers.push(() => window.removeEventListener('popstate', onChange));
}
// Entity-context change events. Each is optional — a host may not implement a
// given namespace yet — so subscribe defensively and collect any disposer.
const subscribeEntity = (
getNamespace: () => any,
method: string,
): void => {
const sub = tryCall(() => getNamespace()?.[method]?.(onChange));
if (sub?.dispose) {
disposers.push(() => sub.dispose());
}
};
subscribeEntity(() => (core as any).explore, 'onDidChangeChart');
subscribeEntity(() => (core as any).dashboard, 'onDidChangeDashboard');
subscribeEntity(() => (core as any).dataset, 'onDidChangeDataset');
// SQL Lab tab switches/renames happen without a route change.
subscribeEntity(() => (core as any).sqlLab, 'onDidChangeActiveTab');
subscribeEntity(() => (core as any).sqlLab, 'onDidChangeTabTitle');
return () => disposers.forEach(dispose => dispose());
};
export const getPageContext = (): PageContext => {
const { pathname, href } =
typeof window !== 'undefined'
? window.location
: { pathname: '', href: '' };
return {
pageType: readPageType(pathname),
dashboard: tryCall(() => (core as any).dashboard?.getCurrentDashboard?.()),
chart: tryCall(() => (core as any).explore?.getCurrentChart?.()),
dataset: tryCall(() => (core as any).dataset?.getCurrentDataset?.()),
sqlLab: readSqlLabTab(),
href,
};
};

View File

@@ -0,0 +1,30 @@
/**
* 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.
*/
/**
* Module Federation entry. The host loads `./index` and invokes the factory;
* the side effect below registers the view + commands. The host's loader
* intercepts registerView calls to collect disposables for deactivation, so
* returning the master Disposable here is also captured by the test harness
* for direct assertion.
*/
import { activate } from './activate';
export const disposable = activate();

View File

@@ -0,0 +1,57 @@
/**
* 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.
*/
/**
* Module-scoped open/closed state plus a tiny emitter the UI subscribes to.
*
* Lives entirely inside the extension — never reaches into the host store.
* Reset on dispose so re-activation starts cleanly.
*/
export type OpenStateListener = (open: boolean) => void;
let open = false;
const listeners = new Set<OpenStateListener>();
export const isOpen = (): boolean => open;
export const setOpen = (next: boolean): void => {
if (next === open) return;
open = next;
listeners.forEach(fn => {
try {
fn(open);
} catch {
// A listener throwing must not block other listeners or flip our state back.
}
});
};
export const subscribe = (fn: OpenStateListener): (() => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
/** Drains listeners and resets state. Called from the master Disposable. */
export const resetState = (): void => {
open = false;
listeners.clear();
};

View File

@@ -0,0 +1,73 @@
/**
* 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.
*/
/**
* Mock streaming reply used to validate stream teardown semantics.
*
* The reference chatbot is environment-validation only — there is no LLM.
* This iterator yields canned tokens on a timer and exits cleanly when its
* AbortSignal is fired. Disposal of the extension aborts any in-flight
* controller, which is the contract that proves async cancellation works.
*/
const TICK_MS = 40;
const buildReply = (prompt: string): string => {
const trimmed = prompt.trim();
if (!trimmed) {
return 'Reference chatbot online. Send a message to validate streaming.';
}
return (
`[reference-chatbot] received "${trimmed}". ` +
'Streaming token-by-token to validate cancellation and teardown.'
);
};
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
});
export async function* streamReply(
prompt: string,
signal: AbortSignal,
): AsyncIterableIterator<string> {
const tokens = buildReply(prompt).split(/(\s+)/);
for (const token of tokens) {
if (signal.aborted) return;
try {
await sleep(TICK_MS, signal);
} catch {
return;
}
yield token;
}
}

View File

@@ -0,0 +1,46 @@
/**
* 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.
*/
/**
* Module-scoped registry of in-flight stream AbortControllers.
*
* Lets the master Disposable abort any running stream even when the panel
* is unmounted by a route change or by re-activation of the extension.
*/
const active = new Set<AbortController>();
export const registerActiveController = (c: AbortController): void => {
active.add(c);
};
export const unregisterActiveController = (c: AbortController): void => {
active.delete(c);
};
export const abortAllActiveControllers = (): void => {
active.forEach(c => {
try {
c.abort();
} catch {
// ignore — abort() should not throw, but stay defensive.
}
});
active.clear();
};

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2019",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "es2019"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/__tests__"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@apache-superset/core": ["src/__tests__/sdkMock.ts"]
},
"typeRoots": [
"./node_modules/@types",
"../../superset-frontend/node_modules/@types"
],
"types": ["jest", "node"]
},
"include": ["src"],
"exclude": []
}

View File

@@ -0,0 +1,108 @@
/**
* 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.
*/
const path = require('path');
const fs = require('fs');
const { ModuleFederationPlugin } = require('webpack').container;
const packageConfig = require('./package.json');
const extensionConfig = require('./extension.json');
const MODULE_FEDERATION_NAME = 'apacheSuperset_altChatbot';
/**
* Emits the `manifest.json` the host reads from the extension `dist/` root.
*
* The host (`superset/extensions/utils.py`) expects an extension dist laid out
* as `dist/manifest.json` plus the federated bundle under `dist/frontend/dist/`.
* The manifest carries `extension.json` verbatim, plus the composite `id` and a
* `frontend` block naming the content-hashed `remoteEntry` so the host can load
* the right file. Because the hash is only known after the build, the manifest
* is written from the final asset names rather than checked in.
*/
class EmitManifestPlugin {
apply(compiler) {
compiler.hooks.afterEmit.tap('EmitManifestPlugin', compilation => {
const assets = Object.keys(compilation.assets);
const remoteEntry = assets.find(name => /^remoteEntry\..*\.js$/.test(name));
if (!remoteEntry) {
throw new Error('EmitManifestPlugin: no remoteEntry asset was emitted');
}
const manifest = {
...extensionConfig,
id: `${extensionConfig.publisher}.${extensionConfig.name}`,
frontend: {
remoteEntry,
moduleFederationName: MODULE_FEDERATION_NAME,
},
};
fs.writeFileSync(
path.resolve(__dirname, 'dist', 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
);
});
}
}
module.exports = (env, argv) => {
const isProd = argv.mode === 'production';
return {
entry: isProd ? {} : './src/index.tsx',
mode: isProd ? 'production' : 'development',
devtool: isProd ? false : 'eval-cheap-module-source-map',
devServer: {
port: 3031,
headers: { 'Access-Control-Allow-Origin': '*' },
},
output: {
clean: true,
filename: isProd ? undefined : '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist', 'frontend', 'dist'),
publicPath: `/api/v1/extensions/${extensionConfig.publisher}/${extensionConfig.name}/`,
},
resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
externalsType: 'window',
externals: { '@apache-superset/core': 'superset' },
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
],
},
plugins: [
new ModuleFederationPlugin({
name: MODULE_FEDERATION_NAME,
filename: 'remoteEntry.[contenthash].js',
exposes: { './index': './src/index.tsx' },
shared: {
react: {
singleton: true,
requiredVersion: packageConfig.peerDependencies.react,
import: false,
},
'react-dom': {
singleton: true,
requiredVersion: packageConfig.peerDependencies['react-dom'],
import: false,
},
},
}),
new EmitManifestPlugin(),
],
};
};

View File

@@ -80,7 +80,7 @@ const restrictedImportsRules = {
'no-jest-mock-console': {
name: 'jest-mock-console',
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
}
},
};
module.exports = {

View File

@@ -18,6 +18,22 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./dashboard": {
"types": "./lib/dashboard/index.d.ts",
"default": "./lib/dashboard/index.js"
},
"./dataset": {
"types": "./lib/dataset/index.d.ts",
"default": "./lib/dataset/index.js"
},
"./explore": {
"types": "./lib/explore/index.d.ts",
"default": "./lib/explore/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -213,6 +213,55 @@ export declare interface Event<T> {
(listener: (e: T) => any, thisArgs?: any): Disposable;
}
/**
* Context handed to an extension's `activate` function.
*
* The extension binds the lifetime of everything it registers to this object by
* pushing the returned {@link Disposable}s onto `subscriptions`. Because the
* context is owned by the extension for as long as it is active, registrations
* performed asynchronously (after an `await`, in a timer, or in an event
* callback) are tracked just the same as synchronous ones — the host disposes
* the whole `subscriptions` array on deactivation.
*
* @example
* ```typescript
* export function activate(context: ExtensionContext) {
* context.subscriptions.push(
* commands.registerCommand('my_ext.hello', () => {}),
* );
* }
* ```
*/
export interface ExtensionContext {
/**
* Disposables to be cleaned up when the extension is deactivated. Push every
* {@link Disposable} returned by a `register*` call here.
*/
subscriptions: { dispose(): void }[];
}
/**
* Shape of an extension's entry module (its `./index`).
*
* Extensions are encouraged to export an `activate(context)` function so that
* their registrations are tracked via `context.subscriptions` regardless of
* whether they run synchronously or asynchronously. For backward compatibility,
* a module may instead register its contributions as top-level side effects when
* the module is evaluated; such registrations are only tracked when performed
* synchronously during module evaluation.
*/
export interface ExtensionModule {
/**
* Called by the host once the extension module has loaded. May be async; the
* host awaits it before considering the extension active.
*/
activate?(context: ExtensionContext): void | Promise<void>;
/**
* Optional hook called before the host disposes `context.subscriptions`.
*/
deactivate?(): void | Promise<void>;
}
/**
* Represents a Superset extension with its metadata.
* Extensions are modular components that can extend Superset's functionality

View File

@@ -43,6 +43,9 @@ export type SqlLabLocation =
| 'results'
| 'queryHistory';
/** Valid locations within the app shell (persist across all routes). */
export type AppLocation = 'chatbot';
/**
* Nested structure for view contributions by scope and location.
* @example
@@ -55,6 +58,7 @@ export type SqlLabLocation =
*/
export interface ViewContributions {
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
app?: Partial<Record<AppLocation, View[]>>;
}
/**

View File

@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Dashboard namespace for Superset extensions (P3).
*
* Exposes dashboard identity and filter state as a stable semantic API.
* Extensions must not depend on the Redux dashboard slice structure directly.
*/
import { Event } from '../common';
/**
* A single native filter's current selected value(s).
* The value type is intentionally kept as `unknown` because filter values
* are heterogeneous (date ranges, string lists, numbers, etc.).
*/
export interface FilterValue {
/** The filter's stable id. */
filterId: string;
/** Display label of the filter. */
label: string;
/** Currently applied value, or `null` when the filter is cleared. */
value: unknown;
}
/**
* Normalized dashboard context exposed to extensions on the Dashboard page.
*/
export interface DashboardContext {
/** Numeric dashboard id. */
dashboardId: number;
/** Display title of the dashboard. */
title: string;
/**
* Active native filter values keyed by filter id.
* Only includes filters that have a value applied.
*/
filters: FilterValue[];
}
/**
* Returns the normalized dashboard context for the page currently being viewed,
* or `undefined` when the user is not on a Dashboard page.
*
* @example
* ```typescript
* const dash = dashboard.getCurrentDashboard();
* if (dash) {
* console.log(dash.title, dash.filters);
* }
* ```
*/
export declare function getCurrentDashboard(): DashboardContext | undefined;
/**
* Event fired when the dashboard identity or its active filter values change.
* Fired on native filter value changes and on navigation to a different dashboard.
*
* @example
* ```typescript
* const sub = dashboard.onDidChangeDashboard(dash => {
* chatbot.updateContext({ dashboard: dash });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeDashboard: Event<DashboardContext>;

View File

@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Dataset namespace for Superset extensions (P3).
*
* Exposes the dataset currently being viewed as a stable semantic API.
* Aligned with backend-enforced dataset visibility and column-access semantics.
*/
import { Event } from '../common';
/**
* Normalized dataset context exposed to extensions on the Dataset page.
*/
export interface DatasetContext {
/** Numeric dataset id. */
datasetId: number;
/** Display name (table name or virtual dataset name). */
datasetName: string;
/** Schema the dataset belongs to, if applicable. */
schema: string | null;
/** Catalog the dataset belongs to, if applicable. */
catalog: string | null;
/** Database name backing this dataset. */
databaseName: string | null;
/** Whether this is a virtual (SQL-defined) dataset. */
isVirtual: boolean;
}
/**
* Returns the normalized dataset context for the page currently being viewed,
* or `undefined` when the user is not on a Dataset page.
*
* @example
* ```typescript
* const ds = dataset.getCurrentDataset();
* if (ds) {
* console.log(ds.datasetName, ds.schema);
* }
* ```
*/
export declare function getCurrentDataset(): DatasetContext | undefined;
/**
* Event fired when the focused dataset changes (e.g. the user navigates to a
* different dataset detail page).
*
* @example
* ```typescript
* const sub = dataset.onDidChangeDataset(ds => {
* chatbot.updateContext({ dataset: ds });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeDataset: Event<DatasetContext>;

View File

@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Explore namespace for Superset extensions (P3).
*
* Exposes the current chart/explore context as a stable semantic API.
* Normalized over Explore Redux state — extensions must not depend on
* the Redux slice structure directly.
*/
import { Event } from '../common';
/**
* Normalized chart context exposed to extensions during an Explore session.
* Covers saved chart identity and transient editing context; excludes raw
* form-data internals and datasource-implementation details.
*/
export interface ChartContext {
/** The saved chart id, or `null` when the chart has not been persisted. */
chartId: number | null;
/** Display name of the saved chart, or `null` for a new/unsaved chart. */
chartName: string | null;
/** The visualization type currently selected in the editor. */
vizType: string;
/** Id of the datasource backing the chart (physical or virtual dataset). */
datasourceId: number | null;
/** Human-readable datasource name. */
datasourceName: string | null;
}
/**
* Returns the normalized chart context for the active Explore session, or
* `undefined` when the user is not on the Explore page.
*
* @example
* ```typescript
* const chart = explore.getCurrentChart();
* if (chart) {
* console.log(chart.vizType, chart.chartName);
* }
* ```
*/
export declare function getCurrentChart(): ChartContext | undefined;
/**
* Event fired when the chart context changes within the active Explore session
* (e.g. when the viz type, datasource, or saved name changes).
* Not fired during route changes — subscribe to `navigation.onDidChangePage` for those.
*
* @example
* ```typescript
* const sub = explore.onDidChangeChart(chart => {
* chatbot.updateContext({ chart });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeChart: Event<ChartContext>;

View File

@@ -19,9 +19,13 @@
export * as common from './common';
export * as authentication from './authentication';
export * as commands from './commands';
export * as dashboard from './dashboard';
export * as dataset from './dataset';
export * as editors from './editors';
export * as explore from './explore';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Navigation namespace for Superset extensions (P3).
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — use the surface-specific namespace
* (`explore`, `dashboard`, `dataset`) to retrieve entity payloads.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces where `explore.getCurrentChart()` /
* `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a
* concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are
* the browse/list surfaces, distinct from those because no single entity is
* active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves;
* `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages,
* which are not the editor. `'other'` covers any route not explicitly enumerated.
*/
export type PageType =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home'
| 'other';
/**
* Returns the current page surface type.
*
* @example
* ```typescript
* const pageType = navigation.getPageType();
* if (pageType === 'dashboard') {
* const ctx = dashboard.getCurrentDashboard();
* }
* ```
*/
export declare function getPageType(): PageType;
/**
* Event fired whenever the user navigates to a different surface.
* Use the surface-specific namespace to read entity context after the event.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(pageType => {
* if (pageType === 'dashboard') {
* const ctx = dashboard.getCurrentDashboard();
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<PageType>;

View File

@@ -48,6 +48,12 @@ export interface View {
name: string;
/** Optional description of the view, for display in contribution manifests. */
description?: string;
/**
* Optional icon identifier for the view, used in admin pickers and manifest
* listings. Static — set once at registerView() time.
* Dynamic icon states (e.g. notification badge) are the extension's concern.
*/
icon?: string;
}
/**
@@ -56,12 +62,12 @@ export interface View {
* The view provider function is called when the UI renders the location,
* and should return a React element to display.
*
* @param view The view descriptor (id and name).
* @param view The view descriptor (id, name, and optional icon/description).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param provider A function that returns the React element to render.
* @returns A Disposable that unregisters the view when disposed.
*
* @example
* @example SQL Lab panel
* ```typescript
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' },
@@ -69,6 +75,15 @@ export interface View {
* () => <ResultStatsPanel />,
* );
* ```
*
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
* ```typescript
* views.registerView(
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
* 'superset.chatbot',
* () => <ChatbotApp />,
* );
* ```
*/
export declare function registerView(
view: View,
@@ -76,6 +91,21 @@ export declare function registerView(
provider: () => ReactElement,
): Disposable;
/**
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
*
* Extension authors should use this type when calling `registerView` for the
* chatbot area. It is identical to {@link View} but makes the registration
* intent explicit and allows future narrowing (e.g. required `icon`).
*
* @example
* ```typescript
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
* ```
*/
export type ChatbotView = View;
/**
* Retrieves all views registered at a specific location.
*

View File

@@ -519,7 +519,8 @@ const Select = forwardRef(
handleSelectAll();
}}
>
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
{t('Select all')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
</Button>
<Button
type="link"
@@ -536,7 +537,8 @@ const Select = forwardRef(
handleDeselectAll();
}}
>
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
{t('Clear')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
</Button>
</StyledBulkActionsContainer>
),

View File

@@ -182,10 +182,7 @@ testWithAssets(
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
postsAfterClearAll.push(req.url());
}
};

View File

@@ -288,9 +288,7 @@ describe('BigNumberWithTrendline transformProps', () => {
height: 300,
queriesData: [
{
data: [
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
data: [{ __timestamp: 1, value: 100 }] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},

View File

@@ -284,8 +284,11 @@ function Echart(
// setOption(notMerge:true) replaces the dataZoom config, dropping any
// range the user has engaged. Preserve it across the call.
const previousZoom = notMerge
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
?.dataZoom
? (
chartRef.current?.getOption() as {
dataZoom?: DataZoomComponentOption[];
}
)?.dataZoom
: undefined;
chartRef.current?.setOption(themedEchartOptions, {
notMerge,

View File

@@ -0,0 +1,107 @@
/**
* 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 React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { views } from 'src/core';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import ChatbotMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('renders nothing when no chatbot extension is registered', () => {
render(<ChatbotMount />);
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
});
test('renders the registered chatbot inside the fixed mount slot', () => {
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
render(<ChatbotMount />);
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
});
test('renders only the first-to-register chatbot when several are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First Bubble');
const secondProvider = () =>
React.createElement('div', null, 'Second Bubble');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
render(<ChatbotMount />);
expect(screen.getByText('First Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
});
test('isolates a failing chatbot so it does not crash the host', () => {
const FailingChatbot = () => {
throw new Error('chatbot blew up');
};
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => React.createElement(FailingChatbot),
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
});
test('isolates a chatbot whose provider function itself throws', () => {
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => {
throw new Error('provider blew up');
},
),
);
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatbotMount />)).not.toThrow();
});

View File

@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
import { SupersetClient } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { getActiveChatbot } from 'src/core/chatbot';
import { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
import { subscribeToExtensionSettings } from 'src/core/extensions';
const CHATBOT_EDGE_MARGIN = 24;
/**
* Wraps the chatbot provider in a React component so that ErrorBoundary can
* catch synchronous throws from the provider function itself. Calling
* `provider()` inline (e.g. `{activeChatbot.provider()}`) would throw outside
* React's render boundary and crash the host.
*/
const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatbotMount = () => {
const theme = useTheme();
const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const applySettings = useCallback(
(settings: {
active_chatbot_id: string | null;
enabled: Record<string, boolean>;
}) => {
setAdminSelectedId(settings.active_chatbot_id ?? null);
setEnabledMap(settings.enabled ?? {});
},
[],
);
useEffect(() => {
let cancelled = false;
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => {
if (cancelled) return;
applySettings(json.result ?? { active_chatbot_id: null, enabled: {} });
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
// enabledMap stays {} which getActiveChatbot treats as all-enabled.
setAdminSelectedId(null);
});
return () => {
cancelled = true;
};
}, [applySettings]);
useEffect(() => subscribeToExtensionSettings(applySettings), [applySettings]);
const registryVersion = useSyncExternalStore(
subscribeToRegistry,
getRegistryVersion,
);
const activeChatbot = useMemo(
() => getActiveChatbot(adminSelectedId, enabledMap),
[adminSelectedId, enabledMap, registryVersion],
);
if (!activeChatbot) {
return null;
}
return (
<div
data-test="chatbot-mount"
css={css`
position: fixed;
right: ${CHATBOT_EDGE_MARGIN}px;
bottom: ${CHATBOT_EDGE_MARGIN}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary>
<ChatbotRenderer provider={activeChatbot.provider} />
</ErrorBoundary>
</div>
);
};
export default ChatbotMount;

View File

@@ -0,0 +1,198 @@
/**
* 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 React from 'react';
import * as viewsModule from 'src/core/views';
import { views } from 'src/core/views';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getActiveChatbot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot resolves the single registered chatbot', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
const active = getActiveChatbot();
expect(active).toEqual({ id: 'superset.chatbot', provider });
});
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
const active = getActiveChatbot();
expect(active?.id).toBe('first.chatbot');
expect(active?.provider).toBe(firstProvider);
});
test('getActiveChatbot ignores views registered at other locations', () => {
const provider = () => React.createElement('div', null, 'Panel');
disposables.push(
views.registerView(
{ id: 'some.panel', name: 'Some Panel' },
'sqllab.panels',
provider,
),
);
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
const disposable = views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
);
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
disposable.dispose();
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot honours the admin-pinned selection', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
const active = getActiveChatbot('second.chatbot');
expect(active?.id).toBe('second.chatbot');
expect(active?.provider).toBe(secondProvider);
});
test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
const provider = () => React.createElement('div', null, 'First');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
// 'stale.chatbot' was once the admin pin but is no longer registered.
const active = getActiveChatbot('stale.chatbot');
expect(active?.id).toBe('first.chatbot');
});
test('getActiveChatbot excludes disabled extensions before applying admin pin', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
// Admin pinned second, but second is disabled — should fall back to first.
const active = getActiveChatbot('second.chatbot', {
'second.chatbot': false,
});
expect(active?.id).toBe('first.chatbot');
});
test('getActiveChatbot falls through to the next candidate when one fails to resolve', () => {
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
() => React.createElement('div', null, 'First'),
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
// Simulate a stale registration: the id is still listed but its provider no
// longer resolves. Resolution must skip it instead of returning undefined.
jest
.spyOn(viewsModule, 'getViewProvider')
.mockImplementation((location, id) =>
id === 'first.chatbot' ? undefined : secondProvider,
);
const active = getActiveChatbot();
expect(active?.id).toBe('second.chatbot');
expect(active?.provider).toBe(secondProvider);
jest.restoreAllMocks();
});
test('getActiveChatbot returns undefined when all candidates are disabled', () => {
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => React.createElement('div', null, 'Chatbot'),
),
);
expect(getActiveChatbot(null, { 'superset.chatbot': false })).toBeUndefined();
});

View File

@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
* contribution area.
*
* `superset.chatbot` is a singleton contribution area: multiple chatbot
* extensions may register a view there, but the host renders exactly one.
* This module owns the host-side selection policy.
*
* This is host-internal infrastructure — it is NOT part of the public
* `@apache-superset/core` API. Extensions register via the public
* `views.registerView()`; only the host resolves which one is active.
*/
import { ReactElement } from 'react';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
/**
* The resolved active chatbot: a view id paired with its renderable provider.
*/
export interface ActiveChatbot {
/** The registered view id of the selected chatbot. */
id: string;
/** The provider that renders the chatbot's React element. */
provider: () => ReactElement;
}
/**
* Resolves which single chatbot extension is currently active.
*
* Selection policy:
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
* - Disabled chatbots (per `enabledMap`) are excluded before selection.
* - If `adminSelectedId` matches an enabled registered chatbot, that one wins.
* - Otherwise the first enabled chatbot in registration order is used as a fallback.
*
* @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any.
* @param enabledMap Per-extension enabled flags from the admin settings API.
* @returns The active chatbot's id and provider, or `undefined` if none.
*/
export const getActiveChatbot = (
adminSelectedId?: string | null,
enabledMap?: Record<string, boolean>,
): ActiveChatbot | undefined => {
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
if (registeredIds.length === 0) {
return undefined;
}
const candidates = enabledMap
? registeredIds.filter(id => enabledMap[id] !== false)
: registeredIds;
if (candidates.length === 0) {
return undefined;
}
// Try the admin-pinned id first, then fall back through the remaining
// candidates in registration order. A candidate may fail to resolve if its
// registration became stale, so we keep looking instead of giving up.
const ordered =
adminSelectedId && candidates.includes(adminSelectedId)
? [adminSelectedId, ...candidates.filter(id => id !== adminSelectedId)]
: candidates;
for (const id of ordered) {
const provider = getViewProvider(CHATBOT_LOCATION, id);
if (provider) {
return { id, provider };
}
}
return undefined;
};

View File

@@ -0,0 +1,171 @@
/**
* 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.
*/
// ---------------------------------------------------------------------------
// Captured listeners — allows tests to trigger action notifications manually.
// ---------------------------------------------------------------------------
type ListenerEntry = {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
};
const capturedListeners: ListenerEntry[] = [];
// Declared before jest.mock so the factory closure can reference it.
let mockState: Record<string, unknown>;
jest.mock('src/views/store', () => ({
store: { getState: () => mockState, dispatch: jest.fn() },
listenerMiddleware: {
startListening: (opts: {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
}) => {
const entry = { predicate: opts.predicate, effect: opts.effect };
capturedListeners.push(entry);
return () => {
const idx = capturedListeners.indexOf(entry);
if (idx !== -1) capturedListeners.splice(idx, 1);
};
},
},
}));
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'dashboard') },
}));
function dispatch(actionType: string) {
const action = { type: actionType };
capturedListeners
.filter(e => e.predicate(action))
.forEach(e => e.effect(action));
}
// Imported after mocks
// eslint-disable-next-line import/first
import { dashboard } from './index';
function makeState(
overrides: Partial<{
dashboardInfo: unknown;
nativeFilters: unknown;
dataMask: unknown;
}> = {},
) {
return {
dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' },
nativeFilters: { filters: { 'filter-1': { name: 'Region' } } },
dataMask: { 'filter-1': { filterState: { value: ['West'] } } },
...overrides,
};
}
beforeEach(() => {
mockState = makeState();
});
afterEach(() => {
capturedListeners.length = 0;
jest.restoreAllMocks();
});
test('getCurrentDashboard returns undefined when not on dashboard page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('explore');
expect(dashboard.getCurrentDashboard()).toBeUndefined();
});
test('getCurrentDashboard returns undefined when dashboardInfo is absent', () => {
mockState = makeState({ dashboardInfo: undefined });
expect(dashboard.getCurrentDashboard()).toBeUndefined();
});
test('getCurrentDashboard returns dashboard context with active filters', () => {
expect(dashboard.getCurrentDashboard()).toEqual({
dashboardId: 1,
title: 'Sales',
filters: [{ filterId: 'filter-1', label: 'Region', value: ['West'] }],
});
});
test('getCurrentDashboard excludes filters with null value', () => {
mockState = makeState({
dataMask: { 'filter-1': { filterState: { value: null } } },
});
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
});
test('getCurrentDashboard excludes dataMask entries not in nativeFilters', () => {
mockState = makeState({
dataMask: { 'chart-filter': { filterState: { value: 'foo' } } },
});
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
});
test('filter array value is a defensive copy — mutation does not affect Redux state', () => {
const ctx = dashboard.getCurrentDashboard();
const original = [
...(mockState as any).dataMask['filter-1'].filterState.value,
];
(ctx!.filters[0].value as string[]).push('East');
expect((mockState as any).dataMask['filter-1'].filterState.value).toEqual(
original,
);
});
// Action type strings match the constants in src/dashboard/actions/hydrate
// and src/dataMask/actions — kept as literals so this test file has no
// import dependency on those modules.
test.each([
'HYDRATE_DASHBOARD',
'UPDATE_DATA_MASK',
'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE',
])('onDidChangeDashboard fires on action type %s', actionType => {
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
dispatch(actionType);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ dashboardId: 1, title: 'Sales' }),
);
disposable.dispose();
});
test('onDidChangeDashboard does not fire when not on dashboard page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
dispatch('HYDRATE_DASHBOARD');
expect(listener).not.toHaveBeenCalled();
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
disposable.dispose();
});
test('disposed listener is not called', () => {
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
disposable.dispose();
dispatch('HYDRATE_DASHBOARD');
expect(listener).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,95 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Host-internal implementation of the `dashboard` namespace.
*
* Wraps Redux dashboardInfo and dataMask state and normalizes them into the
* stable `DashboardContext` contract. Extensions must not depend on the Redux
* slice structure directly.
*/
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import {
UPDATE_DATA_MASK,
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
} from 'src/dataMask/actions';
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { createActionListener } from '../utils';
import { navigation } from '../navigation';
type DashboardContext = dashboardApi.DashboardContext;
type FilterValue = dashboardApi.FilterValue;
function buildDashboardContext(): DashboardContext | undefined {
if (navigation.getPageType() !== 'dashboard') return undefined;
const state = store.getState();
const info = (state as any).dashboardInfo;
if (!info?.id) return undefined;
const nativeFilters = (state as any).nativeFilters?.filters ?? {};
const dataMask = (state as any).dataMask ?? {};
const filters: FilterValue[] = Object.entries(dataMask)
.filter(([id, mask]: [string, any]) => {
if (!(id in nativeFilters)) return false;
const value = mask?.filterState?.value;
return value !== null && value !== undefined;
})
.map(([id, mask]: [string, any]) => {
const raw = mask.filterState.value;
return {
filterId: id,
label: nativeFilters[id]?.name ?? id,
value: Array.isArray(raw) ? [...raw] : raw,
};
});
return {
dashboardId: info.id as number,
title: info.dashboard_title ?? info.slug ?? String(info.id),
filters,
};
}
const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
action.type === HYDRATE_DASHBOARD ||
action.type === UPDATE_DATA_MASK ||
action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
buildDashboardContext();
const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
listener: (ctx: DashboardContext) => void,
thisArgs?: any,
) =>
createActionListener<DashboardContext>(
dashboardChangePredicate,
listener,
() => buildDashboardContext() ?? null,
thisArgs,
);
export const dashboard: typeof dashboardApi = {
getCurrentDashboard,
onDidChangeDashboard,
};

View File

@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Host-internal implementation of the `dataset` namespace.
*
* Dataset page components call `setCurrentDataset` to publish context as they
* load. Extensions consume the stable `DatasetContext` contract; they are
* isolated from the page's internal data-fetching implementation.
*/
import type { dataset as datasetApi } from '@apache-superset/core';
import { Disposable } from '../models';
type DatasetContext = datasetApi.DatasetContext;
let currentDataset: DatasetContext | undefined;
const listeners = new Set<(ctx: DatasetContext) => void>();
/**
* Host-internal: called by the Dataset page when its entity loads or changes.
* Not part of the public `@apache-superset/core` API.
*/
export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
currentDataset = ctx;
if (ctx) {
listeners.forEach(fn => fn(ctx));
}
};
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () =>
currentDataset ? { ...currentDataset } : undefined;
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
listener: (ctx: DatasetContext) => void,
thisArgs?: any,
): Disposable => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return new Disposable(() => listeners.delete(bound));
};
export const dataset: typeof datasetApi = {
getCurrentDataset,
onDidChangeDataset,
};

View File

@@ -0,0 +1,157 @@
/**
* 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.
*/
// ---------------------------------------------------------------------------
// Captured listeners — allows tests to trigger action notifications manually.
// ---------------------------------------------------------------------------
type ListenerEntry = {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
};
const capturedListeners: ListenerEntry[] = [];
// Declared before jest.mock so the factory closure can reference it.
let mockState: Record<string, unknown>;
jest.mock('src/views/store', () => ({
store: { getState: () => mockState, dispatch: jest.fn() },
listenerMiddleware: {
startListening: (opts: {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
}) => {
const entry = { predicate: opts.predicate, effect: opts.effect };
capturedListeners.push(entry);
return () => {
const idx = capturedListeners.indexOf(entry);
if (idx !== -1) capturedListeners.splice(idx, 1);
};
},
},
}));
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'explore') },
}));
function dispatch(actionType: string) {
const action = { type: actionType };
capturedListeners
.filter(e => e.predicate(action))
.forEach(e => e.effect(action));
}
// Imported after mocks
// eslint-disable-next-line import/first
import { explore } from './index';
beforeEach(() => {
mockState = {
explore: {
slice: { slice_id: 42, slice_name: 'My Chart' },
datasource: { id: 7, table_name: 'orders' },
controls: { viz_type: { value: 'bar' } },
sliceName: 'My Chart',
form_data: {},
},
};
});
afterEach(() => {
capturedListeners.length = 0;
jest.restoreAllMocks();
});
test('getCurrentChart returns undefined when not on explore page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
expect(explore.getCurrentChart()).toBeUndefined();
});
test('getCurrentChart returns undefined when explore state is absent', () => {
mockState = {};
expect(explore.getCurrentChart()).toBeUndefined();
});
test('getCurrentChart returns chart context from Redux state', () => {
expect(explore.getCurrentChart()).toEqual({
chartId: 42,
chartName: 'My Chart',
vizType: 'bar',
datasourceId: 7,
datasourceName: 'orders',
});
});
test('getCurrentChart returns null chartId for unsaved chart', () => {
mockState = {
explore: {
slice: null,
datasource: { id: 1, table_name: 'events' },
controls: { viz_type: { value: 'line' } },
sliceName: null,
form_data: { viz_type: 'line' },
},
};
expect(explore.getCurrentChart()?.chartId).toBeNull();
});
// Action type strings match the constants in src/explore/actions/exploreActions
// and src/explore/actions/datasourcesActions — kept as literals so this test
// file has no import dependency on those modules.
test.each([
'HYDRATE_EXPLORE',
'UPDATE_FORM_DATA', // SET_FORM_DATA constant resolves to this string
'UPDATE_CHART_TITLE',
'SET_DATASOURCE',
'CREATE_NEW_SLICE',
'SLICE_UPDATED',
])('onDidChangeChart fires on action type %s', actionType => {
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
dispatch(actionType);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ chartId: 42, vizType: 'bar' }),
);
disposable.dispose();
});
test('onDidChangeChart does not fire when page type is not explore', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
dispatch('HYDRATE_EXPLORE');
expect(listener).not.toHaveBeenCalled();
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
disposable.dispose();
});
test('disposed listener is not called', () => {
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
disposable.dispose();
dispatch('HYDRATE_EXPLORE');
expect(listener).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Host-internal implementation of the `explore` namespace.
*
* Wraps Redux explore state and normalizes it into the stable `ChartContext`
* contract. Extensions must not depend on the Redux slice structure directly.
*/
import type { explore as exploreApi } from '@apache-superset/core';
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
import {
CREATE_NEW_SLICE,
SET_FORM_DATA,
SLICE_UPDATED,
UPDATE_CHART_TITLE,
} from 'src/explore/actions/exploreActions';
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { createActionListener } from '../utils';
import { navigation } from '../navigation';
type ChartContext = exploreApi.ChartContext;
function buildChartContext(): ChartContext | undefined {
if (navigation.getPageType() !== 'explore') return undefined;
const state = store.getState();
const exploreState = (state as any).explore;
if (!exploreState) return undefined;
const { slice, datasource, controls } = exploreState;
const vizType: string =
(controls?.viz_type?.value as string) ??
exploreState.form_data?.viz_type ??
'';
return {
chartId: slice?.slice_id ?? null,
chartName: exploreState.sliceName ?? slice?.slice_name ?? null,
vizType,
datasourceId: datasource?.id ?? null,
datasourceName:
datasource?.table_name ?? datasource?.datasource_name ?? null,
};
}
const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
action.type === HYDRATE_EXPLORE ||
action.type === SET_FORM_DATA ||
action.type === UPDATE_CHART_TITLE ||
action.type === SET_DATASOURCE ||
action.type === CREATE_NEW_SLICE ||
action.type === SLICE_UPDATED;
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
buildChartContext();
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
listener: (ctx: ChartContext) => void,
thisArgs?: any,
) =>
createActionListener<ChartContext>(
exploreChangePredicate,
listener,
() => buildChartContext() ?? null,
thisArgs,
);
export const explore: typeof exploreApi = {
getCurrentChart,
onDidChangeChart,
};

View File

@@ -29,3 +29,23 @@ export const extensions: typeof extensionsApi = {
getExtension,
getAllExtensions,
};
type ExtensionSettings = {
active_chatbot_id: string | null;
enabled: Record<string, boolean>;
};
const settingsListeners = new Set<(settings: ExtensionSettings) => void>();
export const notifyExtensionSettingsChanged = (
settings: ExtensionSettings,
): void => {
settingsListeners.forEach(fn => fn(settings));
};
export const subscribeToExtensionSettings = (
listener: (settings: ExtensionSettings) => void,
): (() => void) => {
settingsListeners.add(listener);
return () => settingsListeners.delete(listener);
};

View File

@@ -28,10 +28,14 @@ export const core: typeof coreType = {
export * from './authentication';
export * from './commands';
export * from './dashboard';
export * from './dataset';
export * from './editors';
export * from './explore';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';

View File

@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Reset module state between tests so currentPageType is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPageType returns "other" for unknown pathname', async () => {
const { navigation } = await importNavigation();
expect(navigation.getPageType()).toBe('other');
});
test('getPageType derives page type from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPageType()).toBe('dashboard');
});
test('notifyPageChange updates the current page type', async () => {
const { navigation, notifyPageChange } = await importNavigation();
notifyPageChange('/explore/?form_data={}');
expect(navigation.getPageType()).toBe('explore');
});
test('notifyPageChange fires listeners on page type change', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyPageChange('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab');
expect(navigation.getPageType()).toBe('sqllab');
notifyPageChange('/explore/');
notifyPageChange('/sqllab/');
expect(navigation.getPageType()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/list/');
expect(navigation.getPageType()).toBe('chart_list');
notifyPageChange('/dashboard/list/');
expect(navigation.getPageType()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/tablemodelview/list/');
expect(navigation.getPageType()).toBe('dataset_list');
notifyPageChange('/dataset/42');
expect(navigation.getPageType()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab/');
expect(navigation.getPageType()).toBe('sqllab');
notifyPageChange('/sqllab/history/');
expect(navigation.getPageType()).toBe('query_history');
notifyPageChange('/savedqueryview/list/');
expect(navigation.getPageType()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/add');
expect(navigation.getPageType()).toBe('explore');
});

View File

@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Host-internal implementation of the `navigation` namespace.
*
* Backed by browser location — no Redux dependency.
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
*/
import type { navigation as navigationApi } from '@apache-superset/core';
import { Disposable } from '../models';
type PageType = navigationApi.PageType;
const listeners = new Set<(pageType: PageType) => void>();
function derivePageType(pathname: string): PageType {
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
return 'sqllab';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/dataset/')) return 'dataset';
if (pathname.startsWith('/superset/welcome/')) return 'home';
return 'other';
}
let currentPageType: PageType | undefined;
function getOrInitPageType(): PageType {
if (currentPageType === undefined) {
currentPageType = derivePageType(window.location.pathname);
}
return currentPageType;
}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePageType(pathname);
if (next === getOrInitPageType()) return;
currentPageType = next;
listeners.forEach(fn => fn(next));
};
const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (pageType: PageType) => void,
thisArgs?: any,
): Disposable => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return new Disposable(() => listeners.delete(bound));
};
export const navigation: typeof navigationApi = {
getPageType,
onDidChangePage,
};

View File

@@ -56,6 +56,7 @@ import {
QueryResultContext,
QueryErrorResultContext,
} from './models';
import { navigation } from '../navigation';
const { CTASMethod } = sqlLabApi;
@@ -301,8 +302,15 @@ function createQueryErrorContext(
);
}
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () =>
getTab(activeEditorId());
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => {
// Guard on the page type so the tab does not leak onto non-editor surfaces.
// The SQL Lab Redux slice persists after navigating away, so without this
// guard `getCurrentTab()` would keep returning the last editor's tab on, e.g.,
// a dashboard or list page. Mirrors the page-type guards on
// `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`.
if (navigation.getPageType() !== 'sqllab') return undefined;
return getTab(activeEditorId());
};
const getActivePanel: typeof sqlLabApi.getActivePanel = () => {
const { activeSouthPaneTab } = getSqlLabState();
@@ -452,8 +460,14 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = (
createActionListener(
globalPredicate(SET_ACTIVE_QUERY_EDITOR),
listener,
(action: { type: string; queryEditor: { id: string } }) =>
getTab(action.queryEditor.id),
// Resolve the now-active tab the same way `getCurrentTab()` does (via the
// active-editor / tabHistory state) rather than from the raw action payload.
// The action's `queryEditor` carries the base editor without `unsavedQueryEditor`
// merged, so its `dbId` can still be undefined at this point, which made
// `getTab(action.queryEditor.id)` return undefined and silently swallow the
// event. Reading the resolved active tab keeps this event consistent with the
// getter and fires on every tab switch.
() => getCurrentTab() ?? null,
thisArgs,
);

View File

@@ -119,6 +119,13 @@ jest.mock('src/views/store', () => ({
setupStore: jest.fn(),
}));
// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests
// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to
// assert the off-surface guard) can change the return value.
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'sqllab') },
}));
// Module under test — imported after mocks
// eslint-disable-next-line import/first
import { sqlLab } from '.';
@@ -388,6 +395,31 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => {
disposable.dispose();
});
test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => {
// Switching from the first editor to a second one must report the second tab,
// not the first. Regression guard: resolving the tab from the live active
// editor (via getCurrentTab) instead of the raw action payload.
mockStore.dispatch({
type: ADD_QUERY_EDITOR,
queryEditor: makeSecondEditor(),
});
const listener = jest.fn();
const disposable = sqlLab.onDidChangeActiveTab(listener);
mockStore.dispatch({
type: SET_ACTIVE_QUERY_EDITOR,
queryEditor: { id: 'editor-2' },
});
expect(listener).toHaveBeenCalledTimes(1);
const tab = listener.mock.calls[0][0];
expect(tab.id).toBe('editor-2');
expect(tab.databaseId).toBe(2);
disposable.dispose();
});
test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => {
const listener = jest.fn();
const disposable = sqlLab.onDidCreateTab(listener);
@@ -535,6 +567,13 @@ test('getCurrentTab returns the active tab with correct properties', () => {
expect(tab!.schema).toBe('public');
});
test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
expect(sqlLab.getCurrentTab()).toBeUndefined();
});
test('getActivePanel returns the active south pane tab', () => {
const panel = sqlLab.getActivePanel();
expect(panel.id).toBe('Results');

View File

@@ -17,7 +17,12 @@
* under the License.
*/
import React from 'react';
import { views, resolveView } from './index';
import {
views,
resolveView,
getViewProvider,
getRegisteredViewIds,
} from './index';
const disposables: Array<{ dispose: () => void }> = [];
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
expect(views.getViews('sqllab.panels')).toBeUndefined();
});
test('getViewProvider returns the registered provider for a matching location', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'superset.chatbot',
provider,
),
);
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
});
test('getViewProvider returns undefined when the location does not match', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'sqllab.panels',
provider,
),
);
// Registered, but at a different location.
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
});
test('getViewProvider returns undefined for an unknown id', () => {
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
});
test('getRegisteredViewIds returns ids in registration order', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First' },
'superset.chatbot',
provider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second' },
'superset.chatbot',
provider,
),
);
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
'first.chatbot',
'second.chatbot',
]);
});
test('getRegisteredViewIds returns an empty array for an unused location', () => {
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
});

View File

@@ -39,6 +39,27 @@ const viewRegistry: Map<
const locationIndex: Map<string, Set<string>> = new Map();
/**
* Monotonic version of the view registry. Bumped on every registration or
* disposal so consumers can re-derive state via React's `useSyncExternalStore`.
*/
let registryVersion = 0;
const registrySubscribers = new Set<() => void>();
const notifyRegistry = () => {
registryVersion += 1;
registrySubscribers.forEach(fn => fn());
};
export const subscribeToRegistry = (listener: () => void): (() => void) => {
registrySubscribers.add(listener);
return () => {
registrySubscribers.delete(listener);
};
};
export const getRegistryVersion = () => registryVersion;
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
@@ -46,15 +67,24 @@ const registerView: typeof viewsApi.registerView = (
): Disposable => {
const { id } = view;
const previousLocation = viewRegistry.get(id)?.location;
if (previousLocation && previousLocation !== location) {
locationIndex.get(previousLocation)?.delete(id);
}
viewRegistry.set(id, { view, location, provider });
const ids = locationIndex.get(location) ?? new Set();
ids.add(id);
locationIndex.set(location, ids);
notifyRegistry();
return new Disposable(() => {
const registeredLocation = viewRegistry.get(id)?.location ?? location;
viewRegistry.delete(id);
locationIndex.get(location)?.delete(id);
locationIndex.get(registeredLocation)?.delete(id);
notifyRegistry();
});
};
@@ -77,6 +107,28 @@ const getViews: typeof viewsApi.getViews = (
.filter((c): c is View => !!c);
};
/**
* Host-internal: returns the provider for a registered view id at a location.
* Not part of the public `@apache-superset/core` API — `getViews` stays
* descriptor-only so extensions cannot render each other's views directly.
*/
export const getViewProvider = (
location: string,
id: string,
): (() => ReactElement) | undefined => {
const entry = viewRegistry.get(id);
if (entry?.location !== location) {
return undefined;
}
return entry.provider;
};
/** Host-internal: view ids at a location in registration order. */
export const getRegisteredViewIds = (location: string): string[] => {
const ids = locationIndex.get(location);
return ids ? Array.from(ids) : [];
};
export const views: typeof viewsApi = {
registerView,
getViews,

View File

@@ -188,7 +188,9 @@ function CollectionControl({
// Two items can collide when keyAccessor returns falsy and the index
// fallback is used — breaking dnd-kit reordering and React reconciliation.
// Assign a stable nanoid per item ref when no key is available.
const generatedIdsRef = useRef<WeakMap<CollectionItem, string>>(new WeakMap());
const generatedIdsRef = useRef<WeakMap<CollectionItem, string>>(
new WeakMap(),
);
const itemIds = useMemo(
() =>
value.map(item => {

View File

@@ -16,73 +16,297 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import ExtensionsList from './ExtensionsList';
import fetchMock from 'fetch-mock';
beforeAll(() => fetchMock.unmockGlobal());
// ---------------------------------------------------------------------------
// Module-level mocks
// ---------------------------------------------------------------------------
// Mock initial state for the store
const mockInitialState = {
extensions: {
loading: false,
resourceCount: 2,
resourceCollection: [
{
id: 1,
name: 'Test Extension 1',
enabled: true,
},
{
id: 2,
name: 'Test Extension 2',
enabled: false,
},
],
bulkSelectEnabled: false,
jest.mock('src/views/CRUD/hooks', () => ({
useListViewResource: jest.fn(),
}));
jest.mock('src/components', () => ({
ListView: ({ columns, data }: any) => (
<table>
<tbody>
{(data ?? []).map((row: any) =>
columns.map((col: any) => (
<td key={`${row.id}-${col.id}`}>
{col.Cell
? col.Cell({ row: { original: row } })
: row[col.accessor]}
</td>
)),
)}
</tbody>
</table>
),
}));
// Stub SubMenu so tests aren't coupled to the navigation menu rendering chain.
jest.mock('src/features/home/SubMenu', () => ({
__esModule: true,
default: ({ buttons }: any) => (
<div data-test="submenu">
{(buttons ?? []).map((btn: any, i: number) => (
// eslint-disable-next-line react/no-array-index-key
<button key={i} type="button" onClick={btn.onClick}>
{btn.name}
</button>
))}
</div>
),
}));
// withToasts is the outermost HOC — pass through so callers can inject toast fns.
jest.mock('src/components/MessageToasts/withToasts', () => (C: any) => C);
jest.mock('src/views/contributions', () => ({
CHATBOT_LOCATION: 'superset.chatbot',
}));
jest.mock('src/core/views', () => ({
getRegisteredViewIds: jest.fn(() => []),
subscribeToRegistry: jest.fn(() => () => undefined),
getRegistryVersion: jest.fn(() => 0),
}));
jest.mock('src/core/extensions', () => ({
notifyExtensionSettingsChanged: jest.fn(),
}));
jest.mock('@superset-ui/core', () => {
const actual = jest.requireActual('@superset-ui/core');
return {
...actual,
SupersetClient: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
},
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const { useListViewResource } = jest.requireMock('src/views/CRUD/hooks');
const mockGet = SupersetClient.get as jest.Mock;
const mockPost = SupersetClient.post as jest.Mock;
const mockPut = SupersetClient.put as jest.Mock;
const mockDelete = SupersetClient.delete as jest.Mock;
const EXTENSIONS = [
{
id: 'acme.chatbot',
name: 'chatbot',
publisher: 'acme',
enabled: true,
deletable: true,
},
};
{
id: 'acme.widget',
name: 'widget',
publisher: 'acme',
enabled: true,
deletable: false,
},
];
const mockFetchData = jest.fn();
const mockRefreshData = jest.fn();
function setupHook(extensions = EXTENSIONS) {
useListViewResource.mockReturnValue({
state: {
loading: false,
resourceCount: extensions.length,
resourceCollection: extensions,
},
fetchData: mockFetchData,
refreshData: mockRefreshData,
});
}
const defaultProps = {
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
};
const renderWithStore = (props = {}) =>
render(<ExtensionsList {...defaultProps} {...props} />, {
function renderList(props = {}) {
return render(<ExtensionsList {...defaultProps} {...props} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
initialState: mockInitialState,
});
}
test('renders extensions list with basic structure', async () => {
renderWithStore();
function uploadFile(input: HTMLInputElement, file: File) {
Object.defineProperty(input, 'files', { value: [file], configurable: true });
fireEvent.change(input);
}
// Check that the component renders
expect(document.body).toBeInTheDocument();
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockResolvedValue({
json: { result: { active_chatbot_id: null, enabled: {} } },
});
setupHook();
});
test('displays extension names in the list', async () => {
renderWithStore();
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('renders the import button in the submenu', () => {
renderList();
expect(screen.getByTestId('submenu')).toBeInTheDocument();
});
test('renders extension names in the table', async () => {
renderList();
await waitFor(() => {
expect(screen.getByText('chatbot')).toBeInTheDocument();
expect(screen.getByText('widget')).toBeInTheDocument();
});
});
test('renders delete button only for deletable extensions', async () => {
renderList();
await waitFor(() => {
// Only acme.chatbot has deletable: true
expect(screen.getAllByTestId('delete-extension')).toHaveLength(1);
});
});
test('clicking delete opens confirmation dialog', async () => {
renderList();
await waitFor(() => screen.getByText('chatbot'));
await userEvent.click(screen.getByTestId('delete-extension'));
await waitFor(() => {
// These texts should appear somewhere in the rendered component
expect(document.body).toHaveTextContent(/Extensions/);
expect(
screen.getByText(/are you sure you want to delete/i),
).toBeInTheDocument();
});
});
test('calls toast functions when provided', () => {
test('typing DELETE in confirmation modal triggers delete API call', async () => {
mockDelete.mockResolvedValue({});
renderList();
await waitFor(() => screen.getByText('chatbot'));
await userEvent.click(screen.getByTestId('delete-extension'));
const confirmInput = await screen.findByTestId('delete-modal-input');
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
const modal = screen.getByRole('dialog');
const confirmBtn = within(modal)
.getAllByRole('button', { name: /^delete$/i })
.pop()!;
await userEvent.click(confirmBtn);
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: '/api/v1/extensions/acme/chatbot' }),
);
expect(mockRefreshData).toHaveBeenCalled();
});
});
test('star button shown only for extensions registered as chatbot views', async () => {
const { getRegisteredViewIds } = jest.requireMock('src/core/views');
(getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
renderList();
await waitFor(() => screen.getByText('chatbot'));
expect(screen.getAllByTestId('set-default-chatbot')).toHaveLength(1);
});
test('clicking star calls PUT settings with the extension id', async () => {
const { getRegisteredViewIds } = jest.requireMock('src/core/views');
(getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
mockPut.mockResolvedValue({ json: {} });
renderList();
await waitFor(() => screen.getByText('chatbot'));
await userEvent.click(screen.getByTestId('set-default-chatbot'));
await waitFor(() => {
expect(mockPut).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/extensions/settings',
jsonPayload: expect.objectContaining({
active_chatbot_id: 'acme.chatbot',
}),
}),
);
});
});
test('pressing Enter on star span triggers set-default action', async () => {
const { getRegisteredViewIds } = jest.requireMock('src/core/views');
(getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
mockPut.mockResolvedValue({ json: {} });
renderList();
await waitFor(() => screen.getByText('chatbot'));
fireEvent.keyDown(screen.getByTestId('set-default-chatbot'), {
key: 'Enter',
});
await waitFor(() => {
expect(mockPut).toHaveBeenCalled();
});
});
test('uploading a non-.supx file shows danger toast without calling API', async () => {
const addDangerToast = jest.fn();
const addSuccessToast = jest.fn();
renderList({ addDangerToast });
renderWithStore({
addDangerToast,
addSuccessToast,
});
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
uploadFile(input, new File(['x'], 'evil.zip', { type: 'application/zip' }));
// The component should accept these props without error
expect(addDangerToast).toBeDefined();
expect(addSuccessToast).toBeDefined();
expect(addDangerToast).toHaveBeenCalledWith(expect.stringMatching(/\.supx/i));
expect(mockPost).not.toHaveBeenCalled();
});
test('uploading a .supx file calls POST endpoint and refreshes list', async () => {
mockPost.mockResolvedValue({});
renderList();
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
uploadFile(
input,
new File(['PK'], 'my.supx', { type: 'application/octet-stream' }),
);
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: '/api/v1/extensions/' }),
);
expect(mockRefreshData).toHaveBeenCalled();
});
});

View File

@@ -17,18 +17,48 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { FunctionComponent, useMemo } from 'react';
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { SupersetClient } from '@superset-ui/core';
import {
ConfirmStatusChange,
Switch,
Tooltip,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';
import { ListView } from 'src/components';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import {
getRegisteredViewIds,
subscribeToRegistry,
getRegistryVersion,
} from 'src/core/views';
import { notifyExtensionSettingsChanged } from 'src/core/extensions';
const PAGE_SIZE = 25;
type Extension = {
id: number;
id: string;
name: string;
publisher: string;
enabled: boolean;
deletable: boolean;
};
type ExtensionSettings = {
active_chatbot_id: string | null;
enabled: Record<string, boolean>;
};
interface ExtensionsListProps {
@@ -40,6 +70,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
addSuccessToast,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const {
state: { loading, resourceCount, resourceCollection },
fetchData,
@@ -50,12 +83,141 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
);
const [settings, setSettings] = useState<ExtensionSettings>({
active_chatbot_id: null,
enabled: {},
});
// Always holds the latest settings value so saveSettings never closes over
// a stale snapshot when rapid toggles race against each other.
const settingsRef = useRef(settings);
settingsRef.current = settings;
const registryVersion = useSyncExternalStore(
subscribeToRegistry,
getRegistryVersion,
);
useEffect(() => {
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => setSettings(json.result))
.catch(() => addDangerToast(t('Failed to load extension settings.')));
}, [addDangerToast]);
const saveSettings = useCallback(
(patch: Partial<ExtensionSettings>) => {
const previous = settingsRef.current;
const next = { ...previous, ...patch };
setSettings(next);
notifyExtensionSettingsChanged(next);
SupersetClient.put({
endpoint: '/api/v1/extensions/settings',
jsonPayload: next,
})
.then(() => {
addSuccessToast(t('Settings saved.'));
})
.catch(() => {
// Rollback optimistic update so UI stays consistent with server state.
setSettings(previous);
notifyExtensionSettingsChanged(previous);
addDangerToast(t('Failed to save extension settings.'));
});
},
[addDangerToast, addSuccessToast],
);
const toggleEnabled = useCallback(
(extensionId: string, enabled: boolean) => {
saveSettings({
enabled: { ...settingsRef.current.enabled, [extensionId]: enabled },
});
},
[saveSettings],
);
const setDefaultChatbot = useCallback(
(extensionId: string) => {
const next =
settingsRef.current.active_chatbot_id === extensionId
? null
: extensionId;
saveSettings({ active_chatbot_id: next });
},
[saveSettings],
);
const chatbotIds = useMemo(
() => new Set(getRegisteredViewIds(CHATBOT_LOCATION)),
// registryVersion is intentionally in deps to re-evaluate when views register
// eslint-disable-next-line react-hooks/exhaustive-deps
[registryVersion],
);
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.supx')) {
addDangerToast(t('File must have a .supx extension.'));
e.target.value = '';
return;
}
const formData = new FormData();
formData.append('bundle', file);
setUploading(true);
SupersetClient.post({
endpoint: '/api/v1/extensions/',
body: formData,
headers: { Accept: 'application/json' },
})
.then(() => {
addSuccessToast(t('Extension installed successfully.'));
refreshData();
})
.catch(
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue installing the extension: %s', errMsg),
),
),
)
.finally(() => {
setUploading(false);
e.target.value = '';
});
};
const handleDelete = useCallback(
(extension: Extension) => {
const { publisher, name } = extension;
SupersetClient.delete({
endpoint: `/api/v1/extensions/${publisher}/${name}`,
}).then(
() => {
addSuccessToast(t('Deleted: %s', extension.name));
refreshData();
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting %s: %s', extension.name, errMsg),
),
),
);
},
[addDangerToast, addSuccessToast, refreshData],
);
const columns = useMemo(
() => [
{
Header: t('Name'),
accessor: 'name',
size: 'lg',
id: 'name',
Cell: ({
row: {
@@ -63,18 +225,131 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
},
}: any) => name,
},
{
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
Cell: ({ row: { original } }: any) => {
const { id, deletable } = original;
const isChatbot = chatbotIds.has(id);
const isDefault = settings.active_chatbot_id === id;
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tooltip
id="toggle-enabled-tooltip"
title={t('Enable / Disable')}
placement="bottom"
>
<Switch
data-test="toggle-enabled"
checked={settings.enabled[id] ?? true}
onChange={(checked: boolean) => toggleEnabled(id, checked)}
size="small"
/>
</Tooltip>
{isChatbot && (
<Tooltip
id={`set-chatbot-tooltip-${id}`}
title={
isDefault
? t('Remove default')
: t('Set as default chatbot')
}
placement="bottom"
>
<span
role="button"
tabIndex={0}
data-test="set-default-chatbot"
className="action-button"
onClick={() => setDefaultChatbot(id)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
setDefaultChatbot(id);
}
}}
>
{isDefault ? (
<Icons.StarFilled iconSize="l" />
) : (
<Icons.StarOutlined iconSize="l" />
)}
</span>
</Tooltip>
)}
{deletable && (
<ConfirmStatusChange
title={t('Please confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.name}</b>?
</>
}
onConfirm={() => handleDelete(original)}
>
{(confirmDelete: () => void) => (
<Tooltip
id={`delete-extension-tooltip-${id}`}
title={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
data-test="delete-extension"
className="action-button"
onClick={confirmDelete}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
confirmDelete();
}
}}
>
<Icons.DeleteOutlined iconSize="l" />
</span>
</Tooltip>
)}
</ConfirmStatusChange>
)}
</span>
);
},
},
],
[loading], // We need to monitor loading to avoid stale state in actions
[settings, chatbotIds, toggleEnabled, setDefaultChatbot, handleDelete],
);
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
buttons: [],
buttons: [
{
name: (
<Tooltip
id="import-extension-tooltip"
title={t('Import extension (.supx)')}
placement="bottomRight"
>
<Icons.DownloadOutlined iconSize="l" />
</Tooltip>
),
buttonStyle: 'link',
onClick: handleUploadClick,
loading: uploading,
},
],
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".supx"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<SubMenu {...menuData} />
<ListView<Extension>
columns={columns}

View File

@@ -38,6 +38,13 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
beforeEach(() => {
(ExtensionsLoader as any).instance = undefined;
// Minimal host registry surface the loader wraps during module evaluation.
(window as any).superset = {
commands: { registerCommand: jest.fn() },
menus: { registerMenuItem: jest.fn() },
editors: { registerEditor: jest.fn() },
views: { registerView: jest.fn() },
};
});
test('creates a singleton instance', () => {
@@ -142,3 +149,100 @@ test('logs error when initializeExtensions fails', async () => {
errorSpy.mockRestore();
});
/**
* Stubs the module-federation machinery `loadModule` depends on so a fake
* extension entry module (its `./index` factory) can be loaded in jsdom.
* Returns a cleanup function that restores the patched globals.
*/
function mockRemoteModule(containerName: string, factory: () => unknown) {
const appendChildSpy = jest
.spyOn(document.head, 'appendChild')
.mockImplementation((element: Node) => {
if (element instanceof HTMLScriptElement && element.onload) {
setTimeout(() => (element.onload as any)(new Event('load')), 0);
}
return element;
});
(global as any).__webpack_init_sharing__ = jest
.fn()
.mockResolvedValue(undefined);
(global as any).__webpack_share_scopes__ = { default: {} };
(window as any)[containerName] = {
init: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(factory),
};
return () => {
appendChildSpy.mockRestore();
delete (global as any).__webpack_init_sharing__;
delete (global as any).__webpack_share_scopes__;
delete (window as any)[containerName];
};
}
const remoteExtension = (overrides: Partial<Extension> = {}) =>
createMockExtension({
id: 'remote-ext',
remoteEntry: 'http://example/remoteEntry.js',
...overrides,
});
test('disposes synchronous activation-time registrations on deactivation', async () => {
const loader = ExtensionsLoader.getInstance();
const dispose = jest.fn();
// Legacy side-effect style: register synchronously during module evaluation.
const factory = () => {
window.superset.views.registerView(
{ id: 'remote-ext.view', name: 'View' },
'sqllab.panels',
(() => null) as any,
);
return undefined;
};
const registerView = jest
.spyOn(window.superset.views, 'registerView')
.mockReturnValue({ dispose } as any);
const cleanup = mockRemoteModule('remote-ext', factory);
await loader.initializeExtension(remoteExtension());
loader.deactivateExtension('remote-ext');
expect(dispose).toHaveBeenCalledTimes(1);
registerView.mockRestore();
cleanup();
});
test('tracks registrations made asynchronously inside activate(context)', async () => {
const loader = ExtensionsLoader.getInstance();
const dispose = jest.fn();
const registerView = jest
.spyOn(window.superset.views, 'registerView')
.mockReturnValue({ dispose } as any);
// Modern style: register AFTER an await — the window.superset wrap is already
// gone by then, so this is only tracked because activate pushes to context.
const factory = () => ({
activate: async (context: core.ExtensionContext) => {
await Promise.resolve();
const disposable = window.superset.views.registerView(
{ id: 'remote-ext.async-view', name: 'Async View' },
'sqllab.panels',
(() => null) as any,
);
context.subscriptions.push(disposable);
},
});
const cleanup = mockRemoteModule('remote-ext', factory);
await loader.initializeExtension(remoteExtension());
loader.deactivateExtension('remote-ext');
expect(registerView).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
registerView.mockRestore();
cleanup();
});

View File

@@ -17,10 +17,15 @@
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import type { common as core } from '@apache-superset/core';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
type Extension = core.Extension;
type ExtensionContext = core.ExtensionContext;
type ExtensionModule = core.ExtensionModule;
/**
* Loads extension modules via webpack module federation.
@@ -36,6 +41,9 @@ class ExtensionsLoader {
private initializationPromise: Promise<void> | null = null;
/** Disposables registered by each extension via its context, keyed by extension id. */
private extensionDisposables: Map<string, { dispose(): void }[]> = new Map();
// eslint-disable-next-line no-useless-constructor
private constructor() {
// Private constructor for singleton pattern
@@ -81,14 +89,16 @@ class ExtensionsLoader {
/**
* Initializes a single extension.
* If the extension has a remote entry, loads the module (which triggers
* If the extension has a remote entry, loads the module and runs its
* `activate(context)` hook (or, for legacy extensions, its top-level
* side-effect registrations for commands, views, menus, and editors).
* @param extension The extension to initialize.
*/
public async initializeExtension(extension: Extension) {
try {
if (extension.remoteEntry) {
await this.loadModule(extension);
const subscriptions = await this.loadModule(extension);
this.extensionDisposables.set(extension.id, subscriptions);
}
this.extensionIndex.set(extension.id, extension);
} catch (error) {
@@ -96,15 +106,41 @@ class ExtensionsLoader {
`Failed to initialize extension ${extension.name}\n`,
error,
);
store.dispatch(
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
);
}
}
/**
* Loads a single extension module via webpack module federation.
* The module's top-level side effects fire contribution registrations.
* Deactivates an extension by disposing all of its registered contributions
* and removing it from the index.
*
* Contributions are disposed from the extension's `context.subscriptions`,
* which it populates during `activate(context)`. This tracks registrations
* regardless of when they happen — synchronous or asynchronous — so long as
* the extension pushes each returned Disposable onto its context. Legacy
* extensions that register as top-level side effects are tracked only for the
* synchronous module-evaluation window (see `loadModule`).
*/
public deactivateExtension(id: string): void {
const subscriptions = this.extensionDisposables.get(id);
if (subscriptions) {
subscriptions.forEach(subscription => subscription.dispose());
this.extensionDisposables.delete(id);
}
this.extensionIndex.delete(id);
}
/**
* Loads a single extension module via webpack module federation and runs its
* `activate(context)` hook. Returns the Disposables the extension registered
* (its `context.subscriptions`) so the loader can dispose them on deactivation.
* @param extension The extension to load.
*/
private async loadModule(extension: Extension): Promise<void> {
private async loadModule(
extension: Extension,
): Promise<{ dispose(): void }[]> {
const { remoteEntry, id } = extension;
// Load the remote entry script
@@ -149,8 +185,72 @@ class ExtensionsLoader {
await container.init(__webpack_share_scopes__.default);
const factory = await container.get('./index');
// Execute the module factory - side effects fire registrations
factory();
// The extension binds the lifetime of its registrations to this context by
// pushing the returned Disposables onto `subscriptions`. Because the context
// object outlives the synchronous module-evaluation window, registrations
// performed asynchronously inside `activate` (after an `await`, in a timer,
// or in an event callback) are tracked just like synchronous ones.
const context: ExtensionContext = { subscriptions: [] };
// Backward-compatibility path: extensions that register contributions as
// top-level side effects (rather than via `activate(context)`) do not push
// to `context.subscriptions` themselves. Wrapping the registrars captures
// those disposables — but ONLY while they fire synchronously during module
// evaluation, since the wrap is removed immediately afterwards. Extensions
// that register asynchronously must use `activate(context)` to be tracked.
const originalSuperset = window.superset;
const wrap =
<TArgs extends unknown[]>(
fn: (...args: TArgs) => { dispose(): void },
): ((...args: TArgs) => { dispose(): void }) =>
(...args: TArgs) => {
const disposable = fn(...args);
context.subscriptions.push(disposable);
return disposable;
};
window.superset = {
...originalSuperset,
commands: {
...originalSuperset.commands,
registerCommand: wrap(originalSuperset.commands.registerCommand),
},
menus: {
...originalSuperset.menus,
registerMenuItem: wrap(originalSuperset.menus.registerMenuItem),
},
editors: {
...originalSuperset.editors,
registerEditor: wrap(originalSuperset.editors.registerEditor),
},
views: {
...originalSuperset.views,
registerView: wrap(originalSuperset.views.registerView),
},
};
let module: ExtensionModule | undefined;
try {
// Evaluate the module factory. Legacy extensions fire their contribution
// registrations as a synchronous side effect here; modern extensions
// return a module exposing `activate`.
module = factory() as ExtensionModule | undefined;
} finally {
// Restore the real registrars before `activate` runs so that registrations
// are tracked via `context.subscriptions` (which the extension controls and
// which survives async boundaries) rather than via the synchronous wrap.
window.superset = originalSuperset;
}
// Preferred path: hand the extension its context so it can track every
// registration it makes, synchronous or asynchronous.
if (typeof module?.activate === 'function') {
await module.activate(context);
}
return context.subscriptions;
}
/**

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -109,6 +111,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +130,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +148,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +174,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +211,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +241,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +276,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -16,20 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { logging } from '@apache-superset/core/utils';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
core,
commands,
dashboard,
dataset,
editors,
explore,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
import { notifyPageChange } from 'src/core/navigation';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
@@ -40,9 +45,13 @@ declare global {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
dashboard: typeof dashboard;
dataset: typeof dataset;
editors: typeof editors;
explore: typeof explore;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
};
@@ -53,11 +62,37 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const [initialized, setInitialized] = useState(false);
const location = useLocation();
const prevPathname = useRef<string | null>(null);
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
// Notify the navigation namespace on every route change.
useEffect(() => {
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyPageChange(location.pathname);
}
}, [location.pathname]);
// Log unhandled rejections that may originate from extension code.
// Registered once for the lifetime of the app; does not suppress the
// browser's default error surfacing so host error reporting is unaffected.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
logging.error('[extensions] Unhandled rejection:', event.reason);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener(
'unhandledrejection',
handleUnhandledRejection,
);
};
}, []);
useEffect(() => {
if (initialized) return;
@@ -67,27 +102,33 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
return;
}
// Provide the implementations for @apache-superset/core
// Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
core,
commands,
dashboard,
dataset,
editors,
explore,
extensions,
menus,
navigation,
sqlLab,
views,
};
const setup = async () => {
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
await ExtensionsLoader.getInstance().initializeExtensions();
}
setInitialized(true);
};
// Render the host immediately; extension bundles load in the background.
// ChatbotMount re-resolves reactively once the chatbot extension registers
// (via subscribeToRegistry / getRegistryVersion), so the bubble appears
// without blocking the UI.
setInitialized(true);
setup();
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
}, [initialized, userId]);
if (!initialized) {

View File

@@ -21,12 +21,18 @@ import { render, screen } from 'spec/helpers/testing-library';
import EditDataset from './index';
const DATASET_ENDPOINT = 'glob:*api/v1/dataset/1/related_objects';
// EditPage also fetches the dataset entity itself to publish the `dataset`
// extension-namespace context (setCurrentDataset).
const DATASET_RESOURCE_ENDPOINT = 'glob:*api/v1/dataset/1';
const mockedProps = {
id: '1',
};
fetchMock.get(DATASET_ENDPOINT, { charts: { results: [], count: 2 } });
fetchMock.get(DATASET_RESOURCE_ENDPOINT, {
result: { id: 1, table_name: 'test_table', schema: 'public' },
});
test('should render edit dataset view with tabs', async () => {
render(<EditDataset {...mockedProps} />);

View File

@@ -16,9 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import { setCurrentDataset } from 'src/core/dataset';
import { Badge } from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -47,6 +50,13 @@ interface EditPageProps {
id: string;
}
// Stable no-op error handler so `useSingleViewResource`'s `fetchResource`
// keeps a stable identity across renders (it lists the handler in its deps).
// An inline handler would change every render and re-trigger the fetch effect,
// causing an update loop. Fetch failure is non-fatal here — the dataset
// context simply stays empty.
const noopErrorHandler = () => {};
const TRANSLATIONS = {
USAGE_TEXT: t('Usage'),
COLUMNS_TEXT: t('Columns'),
@@ -62,6 +72,45 @@ const TABS_KEYS = {
const EditPage = ({ id }: EditPageProps) => {
const { usageCount } = useGetDatasetRelatedCounts(id);
// Publish the focused dataset to the `dataset` extension namespace so chatbot
// extensions can read which dataset the user is editing. Cleared on unmount.
const {
state: { resource: datasetResource },
fetchResource,
} = useSingleViewResource<{
id: number;
table_name?: string;
schema?: string | null;
catalog?: string | null;
sql?: string | null;
is_sqllab_view?: boolean;
database?: { database_name?: string };
}>('dataset', t('dataset'), noopErrorHandler);
useEffect(() => {
const datasetId = Number(id);
if (!Number.isNaN(datasetId)) {
fetchResource(datasetId);
}
// `fetchResource` is stable (noopErrorHandler keeps its identity fixed);
// fetch only when the id changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!datasetResource) return undefined;
setCurrentDataset({
datasetId: datasetResource.id,
datasetName: datasetResource.table_name ?? String(datasetResource.id),
schema: datasetResource.schema ?? null,
catalog: datasetResource.catalog ?? null,
databaseName: datasetResource.database?.database_name ?? null,
isVirtual:
Boolean(datasetResource.sql) || !!datasetResource.is_sqllab_view,
});
return () => setCurrentDataset(undefined);
}, [datasetResource]);
const usageTab = (
<TabStyles>
<span>{TRANSLATIONS.USAGE_TEXT}</span>

View File

@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
// Only return permission id=10, not id=20
return Promise.resolve({
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
return Promise.reject(new Error('network error'));
}
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleA.permission_ids.map(pid => ({
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
},
});
}
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({

View File

@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
import type { QueryEditor } from '../SqlLab/types';
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
type LogEventSource =
| 'dashboard'
| 'embedded_dashboard'
| 'explore'
| 'sqlLab'
| 'slice';
interface LogEventData {
source?: LogEventSource;

View File

@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatbotMount from 'src/components/ChatbotMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
</Route>
))}
</Switch>
{/*
The singleton chatbot bubble. Rendered as a sibling of the route
Switch — inside ExtensionsStartup so chatbot extensions have been
loaded and registered, but outside the Switch so the bubble persists
across route changes (SIP §3.2).
*/}
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatbotMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>

View File

@@ -0,0 +1,31 @@
/**
* 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.
*/
/**
* View locations for app-shell extension integration.
*
* These define locations that persist across all routes, mirroring the `app`
* scope of the `ViewContributions` manifest schema.
*/
export const AppViewLocations = {
app: {
chatbot: 'superset.chatbot',
},
} as const;
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,60 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from flask_babel import lazy_gettext as _
from marshmallow import ValidationError
from superset.commands.exceptions import CommandInvalidError, UpdateFailedError
class ExtensionSettingsInvalidError(CommandInvalidError):
message = _("Extension settings parameters are invalid.")
class ExtensionSettingsUpdateFailedError(UpdateFailedError):
message = _("Extension settings could not be updated.")
class ActiveChatbotIdValidationError(ValidationError):
"""Marshmallow validation error wrapping an invalid active_chatbot_id."""
def __init__(self, max_length: int) -> None:
super().__init__(
[
_(
"active_chatbot_id must be null or a string of at most "
"%(max)d characters.",
max=max_length,
)
],
field_name="active_chatbot_id",
)
class EnabledKeyValidationError(ValidationError):
"""Marshmallow validation error wrapping an invalid enabled-map key."""
def __init__(self, max_length: int) -> None:
super().__init__(
[
_(
"enabled keys must be non-empty strings of at most "
"%(max)d characters.",
max=max_length,
)
],
field_name="enabled",
)

View File

@@ -0,0 +1,29 @@
# 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.
from typing import Any
from superset.commands.base import BaseCommand
from superset.daos.extension import get_extension_settings
class GetExtensionSettingsCommand(BaseCommand):
def run(self) -> dict[str, Any]:
self.validate()
return get_extension_settings()
def validate(self) -> None:
return None

View File

@@ -0,0 +1,108 @@
# 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 logging
from functools import partial
from typing import Any
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.extension.settings.exceptions import (
ActiveChatbotIdValidationError,
EnabledKeyValidationError,
ExtensionSettingsInvalidError,
ExtensionSettingsUpdateFailedError,
)
from superset.daos.extension import (
ExtensionEnabledDAO,
ExtensionSettingsDAO,
get_extension_settings,
)
from superset.extensions.models import EXTENSION_ID_MAX_LENGTH
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
class UpdateExtensionSettingsCommand(BaseCommand):
"""Apply a partial update to global extension admin settings.
The payload is a dict that may contain:
* active_chatbot_id: str | None — empty string is normalised to None.
* enabled: dict[str, bool] — per-extension toggle map. Non-bool values
are silently skipped.
Keys not present in the payload are left untouched. Invalid values raise
``ExtensionSettingsInvalidError`` before any write happens.
"""
def __init__(self, body: dict[str, Any]):
self._body = body or {}
@transaction(
on_error=partial(on_error, reraise=ExtensionSettingsUpdateFailedError),
)
def run(self) -> dict[str, Any]:
self.validate()
if "active_chatbot_id" in self._body:
value = self._body["active_chatbot_id"]
active_chatbot_id = str(value) if value else None
ExtensionSettingsDAO.upsert_active_chatbot_id(active_chatbot_id)
enabled = self._body.get("enabled")
if isinstance(enabled, dict):
for extension_id, value in enabled.items():
if not isinstance(value, bool):
continue
ExtensionEnabledDAO.upsert_enabled_flag(extension_id, value)
return get_extension_settings()
def validate(self) -> None:
exceptions: list[ValidationError] = []
if "active_chatbot_id" in self._body:
value = self._body["active_chatbot_id"]
if not self._is_valid_chatbot_id(value):
exceptions.append(
ActiveChatbotIdValidationError(EXTENSION_ID_MAX_LENGTH)
)
enabled = self._body.get("enabled")
if enabled is not None and not self._are_valid_enabled_keys(enabled):
exceptions.append(EnabledKeyValidationError(EXTENSION_ID_MAX_LENGTH))
if exceptions:
raise ExtensionSettingsInvalidError(exceptions=exceptions)
@staticmethod
def _is_valid_chatbot_id(value: Any) -> bool:
# Null or any string up to the column length. An empty string is a
# valid "clear" signal — run() normalises it to None.
if value is None:
return True
return isinstance(value, str) and len(value) <= EXTENSION_ID_MAX_LENGTH
@staticmethod
def _are_valid_enabled_keys(enabled: Any) -> bool:
if not isinstance(enabled, dict):
return False
return all(
isinstance(key, str) and 0 < len(key) <= EXTENSION_ID_MAX_LENGTH
for key in enabled
)

View File

@@ -0,0 +1,77 @@
# 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.
from typing import Any
from superset import db
from superset.daos.base import BaseDAO
from superset.extensions.models import ExtensionEnabled, ExtensionSettings
# The global extension settings live in a single row; id is fixed so the row
# can be fetched and upserted without a secondary lookup.
SETTINGS_ROW_ID = 1
class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]):
"""Persistence for the singleton global extension settings row.
The row (id=1) holds global admin state such as the active chatbot id.
Writes go through a check-then-write upsert that is dialect-agnostic;
callers wrap the operation in ``@transaction`` so the read-then-write
window is serialised and committed atomically.
"""
@staticmethod
def get_settings_row() -> ExtensionSettings | None:
return db.session.get(ExtensionSettings, SETTINGS_ROW_ID)
@classmethod
def upsert_active_chatbot_id(cls, active_chatbot_id: str | None) -> None:
if row := cls.get_settings_row():
row.active_chatbot_id = active_chatbot_id
else:
cls.create(
attributes={
"id": SETTINGS_ROW_ID,
"active_chatbot_id": active_chatbot_id,
}
)
class ExtensionEnabledDAO(BaseDAO[ExtensionEnabled]):
"""Persistence for per-extension enabled flags."""
id_column_name = "extension_id"
@classmethod
def get_enabled_map(cls) -> dict[str, bool]:
return {row.extension_id: row.enabled for row in cls.find_all()}
@classmethod
def upsert_enabled_flag(cls, extension_id: str, enabled: bool) -> None:
if row := cls.find_by_id(extension_id):
row.enabled = enabled
else:
cls.create(attributes={"extension_id": extension_id, "enabled": enabled})
def get_extension_settings() -> dict[str, Any]:
"""Read-only view of the combined extension settings."""
row = ExtensionSettingsDAO.get_settings_row()
return {
"active_chatbot_id": row.active_chatbot_id if row else None,
"enabled": ExtensionEnabledDAO.get_enabled_map(),
}

View File

@@ -15,22 +15,65 @@
# specific language governing permissions and limitations
# under the License.
import mimetypes
import re
from io import BytesIO
from pathlib import Path
from typing import Any
from zipfile import is_zipfile, ZipFile
from flask import send_file
from flask import current_app, request, send_file
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from superset.commands.extension.settings.exceptions import (
ExtensionSettingsInvalidError,
)
from superset.commands.extension.settings.get import GetExtensionSettingsCommand
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
from superset.extensions import security_manager
from superset.extensions.utils import (
build_extension_data,
get_bundle_files_from_zip,
get_extensions,
get_loaded_extension,
)
from superset.utils.core import check_is_safe_zip
# Allowlist for publisher and name path parameters — alphanumeric, hyphens,
# underscores only. Rejects path-traversal attempts (../), URL-encoded slashes,
# and any other characters that could escape EXTENSIONS_PATH.
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# Default 10 MB server-side upload limit; can be overridden via config.
_DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024
def _validate_segment(value: str) -> bool:
"""Return True if *value* is a safe publisher or name segment."""
return bool(_SEGMENT_RE.match(value))
class ExtensionsRestApi(BaseApi):
allow_browser_login = True
resource_name = "extensions"
# FAB's BaseApi defaults csrf_exempt to True; these endpoints use
# cookie/session auth (allow_browser_login) and include state-changing
# routes (settings PUT, upload POST, delete), so CSRF protection must apply.
csrf_exempt = False
class_permission_name = "Extensions"
base_permissions = [
"can_get_list",
"can_get",
"can_put",
"can_post",
"can_delete",
"can_content",
"can_info",
"can_get_settings",
"can_put_settings",
]
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
@@ -158,7 +201,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
# Reconstruct composite ID from publisher and name
if not _validate_segment(publisher) or not _validate_segment(name):
return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
@@ -167,6 +211,265 @@ class ExtensionsRestApi(BaseApi):
extension_data = build_extension_data(extension)
return self.response(200, result=extension_data)
@protect()
@safe
@expose("/", methods=("POST",))
def post(self, **kwargs: Any) -> Response:
"""Upload and install an extension bundle (.supx file).
---
post:
summary: Upload a .supx extension bundle.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
bundle:
type: string
format: binary
description: The .supx extension bundle file.
responses:
201:
description: Extension installed successfully.
content:
application/json:
schema:
type: object
properties:
result:
type: object
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
if not security_manager.is_admin():
return self.response(403, message="Admin access required.")
extensions_path = current_app.config.get("EXTENSIONS_PATH")
if not extensions_path:
return self.response(
400,
message=(
"EXTENSIONS_PATH is not configured. Set it in superset_config.py "
"to enable extension uploads."
),
)
upload = request.files.get("bundle")
if not upload:
return self.response(
400, message="No file provided. Send a 'bundle' field."
)
if not upload.filename or not upload.filename.endswith(".supx"):
return self.response(400, message="File must have a .supx extension.")
max_bytes: int = current_app.config.get(
"EXTENSIONS_MAX_UPLOAD_SIZE", _DEFAULT_MAX_UPLOAD_BYTES
)
raw = upload.read(max_bytes + 1)
if len(raw) > max_bytes:
return self.response(
400,
message=(
f"File exceeds the maximum allowed size of {max_bytes} bytes."
),
)
stream = BytesIO(raw)
if not is_zipfile(stream):
return self.response(400, message="File is not a valid ZIP archive.")
stream.seek(0)
try:
with ZipFile(stream, "r") as zip_file:
check_is_safe_zip(zip_file)
files = list(get_bundle_files_from_zip(zip_file))
extension = get_loaded_extension(files, source_base_path="upload://")
except Exception as ex: # pylint: disable=broad-except
return self.response(400, message=f"Invalid extension bundle: {ex}")
# Validate the manifest id before using it as a filename component.
# The id is publisher.name (e.g. "acme.chatbot"); each segment must pass
# _validate_segment so a crafted bundle cannot write outside EXTENSIONS_PATH
# even though the admin is trusted — defence-in-depth against third-party
# bundles the admin did not author.
manifest_id: str = extension.manifest.id
id_parts = manifest_id.split(".", 1)
if len(id_parts) != 2 or not all( # noqa: PLR2004
_validate_segment(p) for p in id_parts
):
return self.response(
400,
message=(
f"Invalid extension id '{manifest_id}' in manifest. "
"Expected '<publisher>.<name>' with alphanumeric, hyphen, "
"or underscore characters only."
),
)
# Reject bundles whose manifest id collides with a LOCAL_EXTENSIONS entry.
local_ids = {
Path(p).name for p in current_app.config.get("LOCAL_EXTENSIONS", [])
}
if manifest_id in local_ids:
return self.response(
409,
message=(
f"Extension '{manifest_id}' is already installed as a "
"local extension. Remove it from LOCAL_EXTENSIONS before uploading."
),
)
# Persist to EXTENSIONS_PATH so the extension survives restarts.
# Destination filename is built from the validated manifest id, not from the
# uploaded filename, so neither can escape EXTENSIONS_PATH.
dest_dir = Path(extensions_path)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_file = dest_dir / f"{manifest_id}.supx"
stream.seek(0)
dest_file.write_bytes(stream.read())
return self.response(201, result=build_extension_data(extension))
@protect()
@safe
@expose("/<publisher>/<name>", methods=("DELETE",))
def delete(self, publisher: str, name: str, **kwargs: Any) -> Response:
"""Delete an uploaded extension bundle.
---
delete:
summary: Delete an extension by its publisher and name.
parameters:
- in: path
schema:
type: string
name: publisher
- in: path
schema:
type: string
name: name
responses:
200:
description: Extension deleted.
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
if not security_manager.is_admin():
return self.response(403, message="Admin access required.")
if not _validate_segment(publisher) or not _validate_segment(name):
return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
if not extension:
return self.response_404()
# LOCAL_EXTENSIONS are managed via config — cannot be deleted through the UI.
local_paths = {
str((Path(p) / "dist").resolve())
for p in current_app.config.get("LOCAL_EXTENSIONS", [])
}
if extension.source_base_path in local_paths:
return self.response(
400,
message=(
"Local extensions configured via LOCAL_EXTENSIONS cannot be "
"deleted through the UI. Remove them from your configuration."
),
)
# Locate and remove the .supx file from EXTENSIONS_PATH.
extensions_path = current_app.config.get("EXTENSIONS_PATH")
if not extensions_path:
return self.response(
400,
message="EXTENSIONS_PATH is not configured; cannot remove bundle file.",
)
supx_file = Path(extensions_path) / f"{composite_id}.supx"
if not supx_file.exists():
return self.response_404()
supx_file.unlink()
return self.response(200, message="Extension deleted.")
@protect()
@safe
@expose("/settings", methods=("GET",))
def get_settings(self, **kwargs: Any) -> Response:
"""Get global extension admin settings.
No admin gate here by design: authenticated non-admin users need these
settings so the ChatbotMount can read active_chatbot_id on every page.
---
get:
summary: Get extension admin settings (active chatbot, enabled flags).
responses:
200:
description: Extension settings
"""
return self.response(200, result=GetExtensionSettingsCommand().run())
@protect()
@safe
@expose("/settings", methods=("PUT",))
def put_settings(self, **kwargs: Any) -> Response:
"""Update global extension admin settings.
---
put:
summary: Update extension admin settings.
requestBody:
content:
application/json:
schema:
type: object
properties:
active_chatbot_id:
type: string
nullable: true
enabled:
type: object
additionalProperties:
type: boolean
responses:
200:
description: Updated settings
400:
$ref: '#/components/responses/400'
403:
$ref: '#/components/responses/403'
"""
if not security_manager.is_admin():
return self.response(403, message="Admin access required.")
body = request.get_json(silent=True)
if not isinstance(body, dict):
return self.response(400, message="Request body must be a JSON object.")
# Pre-validate so malformed input returns a 400 rather than the 500 that
# the FAB @safe wrapper would produce for an uncaught command exception.
try:
command = UpdateExtensionSettingsCommand(body)
command.validate()
except ExtensionSettingsInvalidError as ex:
return self.response(400, message=str(ex.normalized_messages()))
return self.response(200, result=command.run())
@protect()
@safe
@expose("/<publisher>/<name>/<file>", methods=("GET",))
@@ -210,7 +513,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
# Reconstruct composite ID from publisher and name
if not _validate_segment(publisher) or not _validate_segment(name):
return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)

View File

@@ -0,0 +1,41 @@
# 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.
"""SQLAlchemy models for extension settings persistence."""
from flask_appbuilder import Model
from sqlalchemy import Boolean, Column, Integer, String
# Shared column length for extension/chatbot identifiers; reused by request
# validation so oversized keys are rejected with a 400 before hitting the DB.
EXTENSION_ID_MAX_LENGTH = 250
class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
"""Global admin settings for extensions (singleton row, id=1)."""
__tablename__ = "extension_settings"
id = Column(Integer, primary_key=True)
active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True)
class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods
"""Per-extension enable/disable flag."""
__tablename__ = "extension_enabled"
extension_id = Column(String(EXTENSION_ID_MAX_LENGTH), primary_key=True)
enabled = Column(Boolean, nullable=False, default=True)

View File

@@ -232,12 +232,18 @@ def get_loaded_extension(
def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
local_paths = {
str((Path(p) / "dist").resolve())
for p in current_app.config.get("LOCAL_EXTENSIONS", [])
}
extension_data: dict[str, Any] = {
"id": manifest.id,
"publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",
"dependencies": manifest.dependencies,
"deletable": extension.source_base_path not in local_paths,
}
if manifest.frontend:
frontend = manifest.frontend

View File

@@ -0,0 +1,47 @@
# 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.
"""Add extension_settings table for chatbot admin selection and enable/disable.
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-25 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "b2c3d4e5f6a7"
down_revision = "a1b2c3d4e5f6"
def upgrade() -> None:
op.create_table(
"extension_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
)
op.create_table(
"extension_enabled",
sa.Column("extension_id", sa.String(250), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
op.drop_table("extension_enabled")
op.drop_table("extension_settings")

View File

@@ -0,0 +1,417 @@
# 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.
"""Unit tests for the extensions REST API (POST and DELETE endpoints)."""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from superset.extensions.api import _validate_segment
# The extension routes are only registered when ENABLE_EXTENSIONS is on at
# app-init time, so the endpoint tests parametrize the app fixture to enable it
# (otherwise the route is absent and requests 404).
_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}]
# ---------------------------------------------------------------------------
# _validate_segment helper
# ---------------------------------------------------------------------------
def test_validate_segment_accepts_alphanumeric() -> None:
assert _validate_segment("acme") is True
assert _validate_segment("my-ext") is True
assert _validate_segment("my_ext") is True
assert _validate_segment("Ext123") is True
def test_validate_segment_rejects_traversal() -> None:
assert _validate_segment("..") is False
assert _validate_segment("../etc") is False
assert _validate_segment("acme/bad") is False
assert _validate_segment("acme%2Fbad") is False
assert _validate_segment("") is False
def test_validate_segment_rejects_dots() -> None:
assert _validate_segment("acme.corp") is False
# ---------------------------------------------------------------------------
# Helpers for building fake .supx payloads
# ---------------------------------------------------------------------------
def _make_supx(manifest_id: str = "acme.chatbot") -> bytes:
"""Return minimal valid .supx (zip) bytes with a manifest."""
buf = io.BytesIO()
manifest_json = (
f'{{"id": "{manifest_id}", "name": "Chatbot", "version": "1.0.0",'
f'"publisher": "acme", "description": "test"}}'
)
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("manifest.json", manifest_json)
return buf.getvalue()
def _make_fake_extension(manifest_id: str = "acme.chatbot") -> MagicMock:
ext = MagicMock()
ext.manifest.id = manifest_id
ext.source_base_path = "upload://"
ext.frontend = {}
ext.backend = {}
ext.version = "1.0.0"
ext.name = "Chatbot"
return ext
# ---------------------------------------------------------------------------
# POST /api/v1/extensions/ — upload and install
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
class TestPostEndpoint:
def _post(self, client: Any, data: dict[str, Any], full_api_access: None) -> Any:
return client.post(
"/api/v1/extensions/",
data=data,
content_type="multipart/form-data",
)
def test_non_admin_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=False
)
resp = client.post("/api/v1/extensions/", data={})
assert resp.status_code == 403
def test_missing_extensions_path_returns_400(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict("flask.current_app.config", {"EXTENSIONS_PATH": None})
resp = client.post("/api/v1/extensions/", data={})
assert resp.status_code == 400
assert "EXTENSIONS_PATH" in resp.json["message"]
def test_missing_bundle_field_returns_400(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "bundle" in resp.json["message"]
def test_wrong_extension_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(b"data"), "evil.zip")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert ".supx" in resp.json["message"]
def test_oversize_upload_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "EXTENSIONS_MAX_UPLOAD_SIZE": 10},
)
big = io.BytesIO(b"x" * 20)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (big, "big.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "maximum" in resp.json["message"]
def test_not_a_zip_returns_400(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(b"not a zip"), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "ZIP" in resp.json["message"]
def test_zip_slip_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
"""check_is_safe_zip raises on path-traversal entries inside the zip."""
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
)
mocker.patch(
"superset.extensions.api.check_is_safe_zip",
side_effect=Exception("zip-slip detected"),
)
supx = _make_supx()
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "zip-slip" in resp.json["message"]
def test_local_extensions_collision_returns_409(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{
"EXTENSIONS_PATH": str(tmp_path),
"LOCAL_EXTENSIONS": ["/opt/superset/ext/acme.chatbot"],
},
)
fake_ext = _make_fake_extension("acme.chatbot")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
supx = _make_supx("acme.chatbot")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 409
assert "local extension" in resp.json["message"]
def test_hostile_manifest_id_rejected(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
"""A crafted manifest.id with path traversal must not escape EXTENSIONS_PATH."""
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "LOCAL_EXTENSIONS": []},
)
fake_ext = _make_fake_extension("../../tmp/evil")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
supx = _make_supx("../../tmp/evil")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "Invalid extension id" in resp.json["message"]
def test_happy_path_returns_201(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch.dict(
"flask.current_app.config",
{"EXTENSIONS_PATH": str(tmp_path), "LOCAL_EXTENSIONS": []},
)
fake_ext = _make_fake_extension("acme.chatbot")
mocker.patch(
"superset.extensions.api.get_bundle_files_from_zip", return_value=[]
)
mocker.patch(
"superset.extensions.api.get_loaded_extension", return_value=fake_ext
)
mocker.patch(
"superset.extensions.api.build_extension_data",
return_value={"id": "acme.chatbot"},
)
supx = _make_supx("acme.chatbot")
resp = client.post(
"/api/v1/extensions/",
data={"bundle": (io.BytesIO(supx), "ext.supx")},
content_type="multipart/form-data",
)
assert resp.status_code == 201
assert resp.json["result"]["id"] == "acme.chatbot"
assert (tmp_path / "acme.chatbot.supx").exists()
# ---------------------------------------------------------------------------
# DELETE /api/v1/extensions/<publisher>/<name>
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
class TestDeleteEndpoint:
def test_non_admin_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=False
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 403
def test_path_traversal_publisher_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
# Use percent-encoded dots so Flask routing passes the segment to the
# handler as the string ".." — literal slashes in the path would be
# intercepted by the router before reaching the view.
resp = client.delete("/api/v1/extensions/%2E%2E/passwd")
assert resp.status_code == 400
assert "Invalid" in resp.json["message"]
def test_invalid_name_returns_400(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
resp = client.delete("/api/v1/extensions/acme/bad.name")
assert resp.status_code == 400
assert "Invalid" in resp.json["message"]
def test_unknown_extension_returns_404(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch("superset.extensions.api.get_extensions", return_value={})
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 404
def test_local_extension_cannot_be_deleted(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
local_base = str(tmp_path / "local-ext" / "dist")
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = local_base
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{"LOCAL_EXTENSIONS": [str(tmp_path / "local-ext")]},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 400
assert "LOCAL_EXTENSIONS" in resp.json["message"]
def test_happy_path_deletes_file(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
supx_file = tmp_path / "acme.chatbot.supx"
supx_file.write_bytes(b"fake")
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = "upload://"
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{
"LOCAL_EXTENSIONS": [],
"EXTENSIONS_PATH": str(tmp_path),
},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 200
assert not supx_file.exists()
def test_supx_file_missing_returns_404(
self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
) -> None:
fake_ext = _make_fake_extension("acme.chatbot")
fake_ext.source_base_path = "upload://"
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.get_extensions",
return_value={"acme.chatbot": fake_ext},
)
mocker.patch.dict(
"flask.current_app.config",
{"LOCAL_EXTENSIONS": [], "EXTENSIONS_PATH": str(tmp_path)},
)
resp = client.delete("/api/v1/extensions/acme/chatbot")
assert resp.status_code == 404

View File

@@ -0,0 +1,372 @@
# 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.
"""Unit tests for extension settings persistence and the settings API endpoints.
Persistence is exercised through the public Command + DAO layer:
``UpdateExtensionSettingsCommand`` / ``GetExtensionSettingsCommand`` and the
``ExtensionSettingsDAO`` / ``ExtensionEnabledDAO`` they delegate to.
"""
from __future__ import annotations
from typing import Any
import pytest
# ---------------------------------------------------------------------------
# Settings persistence (Command + DAO) — sqlite-backed round-trip tests
# ---------------------------------------------------------------------------
class TestGetExtensionSettings:
def test_returns_defaults_when_no_rows(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
result = GetExtensionSettingsCommand().run()
assert result["active_chatbot_id"] is None
assert result["enabled"] == {}
def test_round_trips_active_chatbot_id(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run()
result = GetExtensionSettingsCommand().run()
assert result["active_chatbot_id"] == "acme.chatbot"
def test_round_trips_enabled_flags(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
UpdateExtensionSettingsCommand(
{"enabled": {"acme.chatbot": True, "acme.widget": False}}
).run()
result = GetExtensionSettingsCommand().run()
assert result["enabled"]["acme.chatbot"] is True
assert result["enabled"]["acme.widget"] is False
class TestUpdateExtensionSettings:
def test_empty_string_active_chatbot_id_stored_as_none(
self, app_context: Any
) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
# First set a value, then clear it via empty string.
UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run()
UpdateExtensionSettingsCommand({"active_chatbot_id": ""}).run()
assert GetExtensionSettingsCommand().run()["active_chatbot_id"] is None
def test_non_bool_enabled_value_is_skipped(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
# Unique id so the absence assertion can't collide with rows written by
# other tests sharing the module-scoped database.
UpdateExtensionSettingsCommand({"enabled": {"acme.nonbool_skip": "yes"}}).run()
# "yes" is not a bool — the row should not have been written.
result = GetExtensionSettingsCommand().run()
assert "acme.nonbool_skip" not in result["enabled"]
def test_upsert_overwrites_existing_chatbot(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run()
UpdateExtensionSettingsCommand({"active_chatbot_id": "vendor.bot"}).run()
assert GetExtensionSettingsCommand().run()["active_chatbot_id"] == "vendor.bot"
def test_upsert_overwrites_existing_enabled_flag(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": True}}).run()
UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": False}}).run()
assert GetExtensionSettingsCommand().run()["enabled"]["acme.chatbot"] is False
def test_partial_update_leaves_other_keys_intact(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
UpdateExtensionSettingsCommand(
{"active_chatbot_id": "acme.chatbot", "enabled": {"acme.widget": True}}
).run()
# Update only enabled — active_chatbot_id must survive.
UpdateExtensionSettingsCommand({"enabled": {"acme.widget": False}}).run()
result = GetExtensionSettingsCommand().run()
assert result["active_chatbot_id"] == "acme.chatbot"
assert result["enabled"]["acme.widget"] is False
def test_returns_current_state(self, app_context: Any) -> None:
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
result = UpdateExtensionSettingsCommand(
{"active_chatbot_id": "acme.chatbot"}
).run()
assert result["active_chatbot_id"] == "acme.chatbot"
class TestUpdateExtensionSettingsValidation:
def test_non_string_active_chatbot_id_raises(self, app_context: Any) -> None:
from superset.commands.extension.settings.exceptions import (
ExtensionSettingsInvalidError,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
with pytest.raises(ExtensionSettingsInvalidError):
UpdateExtensionSettingsCommand({"active_chatbot_id": 123}).run()
def test_oversized_active_chatbot_id_raises(self, app_context: Any) -> None:
from superset.commands.extension.settings.exceptions import (
ExtensionSettingsInvalidError,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
from superset.extensions.models import EXTENSION_ID_MAX_LENGTH
with pytest.raises(ExtensionSettingsInvalidError):
UpdateExtensionSettingsCommand(
{"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)}
).run()
def test_oversized_enabled_key_raises(self, app_context: Any) -> None:
from superset.commands.extension.settings.exceptions import (
ExtensionSettingsInvalidError,
)
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
from superset.extensions.models import EXTENSION_ID_MAX_LENGTH
with pytest.raises(ExtensionSettingsInvalidError):
UpdateExtensionSettingsCommand(
{"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}}
).run()
def test_null_active_chatbot_id_is_valid(self, app_context: Any) -> None:
from superset.commands.extension.settings.update import (
UpdateExtensionSettingsCommand,
)
# Should not raise.
UpdateExtensionSettingsCommand({"active_chatbot_id": None}).validate()
# ---------------------------------------------------------------------------
# GET /api/v1/extensions/settings
# ---------------------------------------------------------------------------
# The settings routes are only registered when ENABLE_EXTENSIONS is on at
# app-init time, so the endpoint tests parametrize the app fixture to enable it
# (otherwise the route is absent and requests 404).
_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}]
@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
class TestGetSettingsEndpoint:
def test_authenticated_user_can_read(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.GetExtensionSettingsCommand.run",
return_value={"active_chatbot_id": None, "enabled": {}},
)
resp = client.get("/api/v1/extensions/settings")
assert resp.status_code == 200
assert resp.json["result"]["active_chatbot_id"] is None
def test_returns_active_chatbot_and_enabled_map(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.GetExtensionSettingsCommand.run",
return_value={
"active_chatbot_id": "acme.chatbot",
"enabled": {"acme.chatbot": True},
},
)
resp = client.get("/api/v1/extensions/settings")
assert resp.status_code == 200
assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot"
assert resp.json["result"]["enabled"]["acme.chatbot"] is True
# ---------------------------------------------------------------------------
# PUT /api/v1/extensions/settings
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
class TestPutSettingsEndpoint:
def test_non_admin_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=False
)
resp = client.put(
"/api/v1/extensions/settings",
json={"active_chatbot_id": "acme.chatbot"},
)
assert resp.status_code == 403
def test_admin_can_update_active_chatbot(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
return_value={"active_chatbot_id": "acme.chatbot", "enabled": {}},
)
resp = client.put(
"/api/v1/extensions/settings",
json={"active_chatbot_id": "acme.chatbot"},
)
assert resp.status_code == 200
assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot"
def test_empty_body_is_accepted(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
return_value={"active_chatbot_id": None, "enabled": {}},
)
resp = client.put("/api/v1/extensions/settings", json={})
assert resp.status_code == 200
def test_non_object_body_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
run = mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
)
resp = client.put("/api/v1/extensions/settings", json=["not", "an", "object"])
assert resp.status_code == 400
run.assert_not_called()
def test_non_string_active_chatbot_id_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
run = mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
)
# An int must be rejected with a 400, not silently coerced to null.
resp = client.put(
"/api/v1/extensions/settings", json={"active_chatbot_id": 123}
)
assert resp.status_code == 400
run.assert_not_called()
def test_null_active_chatbot_id_is_accepted(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
return_value={"active_chatbot_id": None, "enabled": {}},
)
resp = client.put(
"/api/v1/extensions/settings", json={"active_chatbot_id": None}
)
assert resp.status_code == 200
def test_oversized_active_chatbot_id_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
from superset.extensions.models import EXTENSION_ID_MAX_LENGTH
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
run = mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
)
resp = client.put(
"/api/v1/extensions/settings",
json={"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)},
)
assert resp.status_code == 400
run.assert_not_called()
def test_oversized_enabled_key_rejected(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
from superset.extensions.models import EXTENSION_ID_MAX_LENGTH
mocker.patch(
"superset.extensions.api.security_manager.is_admin", return_value=True
)
run = mocker.patch(
"superset.extensions.api.UpdateExtensionSettingsCommand.run",
)
resp = client.put(
"/api/v1/extensions/settings",
json={"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}},
)
assert resp.status_code == 400
run.assert_not_called()