You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/08/19 14:47:34 UTC
(superset) branch dashboard-v2 updated: feat: support progressive widget schema disclosure (#43262)
This is an automated email from the ASF dual-hosted git repository.
michael-s-molina pushed a commit to branch dashboard-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/dashboard-v2 by this push:
new df3c616ea49 feat: support progressive widget schema disclosure (#43262)
df3c616ea49 is described below
commit df3c616ea4940c7c7e53b32f1cf3ec3030fc7994
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Wed Aug 19 07:47:27 2026 -0700
feat: support progressive widget schema disclosure (#43262)
---
.../src/superset_core/widgets/__init__.py | 25 ++
superset-core/src/superset_core/widgets/base.py | 147 +++++++
.../src/superset_core/widgets/decorators.py | 92 +++++
superset-frontend/packages/superset-core/README.md | 2 +-
.../packages/superset-core/src/dashboard/index.ts | 40 +-
.../packages/superset-core/src/views/index.ts | 2 +-
.../src/core/dashboard/DashboardProvider.test.ts | 138 +++----
.../src/core/dashboard/DashboardProvider.ts | 40 +-
.../src/core/dashboard/RootGrid.test.tsx | 54 +--
superset-frontend/src/core/dashboard/RootGrid.tsx | 69 ++--
...ldingBlockView.test.tsx => WidgetView.test.tsx} | 72 ++--
.../{BuildingBlockView.tsx => WidgetView.tsx} | 157 ++++----
superset-frontend/src/core/dashboard/chartData.ts | 28 +-
.../src/core/dashboard/gridPacking.test.ts | 18 +-
.../src/core/dashboard/gridPacking.ts | 40 +-
superset-frontend/src/core/dashboard/index.ts | 14 +-
.../src/core/dashboard/layoutStyle.ts | 2 +-
superset-frontend/src/core/dashboard/placement.ts | 48 +--
...BuildingBlocks.ts => registerBuiltInWidgets.ts} | 82 ++--
.../src/core/dashboard/resolveBindings.ts | 6 +-
...veBuildingBlockView.ts => resolveWidgetView.ts} | 16 +-
superset-frontend/src/core/dashboard/store.ts | 4 +-
...ckHeaderControl.tsx => widgetHeaderControl.tsx} | 32 +-
.../{blockLabel.test.ts => widgetLabel.test.ts} | 38 +-
.../dashboard/{blockLabel.ts => widgetLabel.ts} | 36 +-
.../AgGridTableWidget.tsx} | 20 +-
.../src/core/dashboard/widgets/BalloonsWidget.tsx | 440 +++++++++++++++++++++
.../CarouselWidget.tsx} | 34 +-
.../ChartWidget.test.tsx} | 8 +-
.../ChartBlock.tsx => widgets/ChartWidget.tsx} | 22 +-
.../CollapsibleWidget.tsx} | 22 +-
.../MarkdownWidget.tsx} | 8 +-
.../MetricTileWidget.tsx} | 18 +-
.../TabsWidget.test.tsx} | 70 ++--
.../TabsBlock.tsx => widgets/TabsWidget.tsx} | 32 +-
.../dashboard/{blocks => widgets}/flowContent.tsx | 67 ++--
.../pages/DashboardBuilderV2/DashboardHeader.tsx | 4 +-
.../DashboardBuilderV2/DashboardProperties.tsx | 8 +-
.../pages/DashboardBuilderV2/EditorPanel.test.tsx | 40 +-
.../src/pages/DashboardBuilderV2/EditorPanel.tsx | 18 +-
.../pages/DashboardBuilderV2/Inspector.test.tsx | 86 ++--
.../src/pages/DashboardBuilderV2/Inspector.tsx | 122 ++++--
.../src/pages/DashboardBuilderV2/Outline.test.tsx | 26 +-
.../src/pages/DashboardBuilderV2/Outline.tsx | 26 +-
.../src/pages/DashboardBuilderV2/Palette.tsx | 38 +-
.../src/pages/DashboardBuilderV2/PropsForm.tsx | 8 +-
.../DashboardBuilderV2/SchemaControlPanel.test.tsx | 146 +++++++
.../DashboardBuilderV2/SchemaControlPanel.tsx | 256 ++++++++++++
.../src/pages/DashboardBuilderV2/index.test.tsx | 26 +-
.../src/pages/DashboardBuilderV2/index.tsx | 28 +-
.../DashboardBuilderV2/inferPropsSchema.test.ts | 4 +-
.../pages/DashboardBuilderV2/inferPropsSchema.ts | 8 +-
.../DashboardBuilderV2/schemaControlRenderers.tsx | 124 ++++++
.../schemaControlledWidgets.test.ts | 70 ++++
.../DashboardBuilderV2/schemaControlledWidgets.ts | 87 ++++
superset/core/api/core_api_injection.py | 64 +++
superset/initialization/__init__.py | 2 +
superset/mcp_service/app.py | 4 +
superset/mcp_service/widgets/__init__.py | 18 +
superset/mcp_service/widgets/tool/__init__.py | 24 ++
.../widgets/tool/get_widget_control_schema.py | 115 ++++++
.../mcp_service/widgets/tool/list_widget_types.py | 62 +++
superset/mcp_service/widgets/utils.py | 54 +++
superset/widgets/__init__.py | 35 ++
superset/widgets/api.py | 291 ++++++++++++++
superset/widgets/builtin.py | 142 +++++++
superset/widgets/controls.py | 291 ++++++++++++++
superset/widgets/registry.py | 32 ++
superset/widgets/schema_tools.py | 248 ++++++++++++
tests/unit_tests/mcp_service/widgets/__init__.py | 16 +
.../mcp_service/widgets/tool/__init__.py | 16 +
.../mcp_service/widgets/tool/test_widget_tools.py | 119 ++++++
tests/unit_tests/widgets/__init__.py | 16 +
tests/unit_tests/widgets/conftest.py | 30 ++
tests/unit_tests/widgets/test_builtin.py | 95 +++++
tests/unit_tests/widgets/test_registry.py | 128 ++++++
tests/unit_tests/widgets/test_schema_tools.py | 125 ++++++
77 files changed, 4208 insertions(+), 787 deletions(-)
diff --git a/superset-core/src/superset_core/widgets/__init__.py b/superset-core/src/superset_core/widgets/__init__.py
new file mode 100644
index 00000000000..6982023ff9e
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/__init__.py
@@ -0,0 +1,25 @@
+# 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.
+
+"""Public contract for registering Dashboard V2 widgets.
+
+Re-exported with the redundant-alias form so these are recognized as
+intentional re-exports (this codebase does not use ``__all__``).
+"""
+
+from superset_core.widgets.base import Widget as Widget
+from superset_core.widgets.decorators import widget as widget
diff --git a/superset-core/src/superset_core/widgets/base.py b/superset-core/src/superset_core/widgets/base.py
new file mode 100644
index 00000000000..7ac2ad93e73
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/base.py
@@ -0,0 +1,147 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Base class for a Dashboard V2 widget's backend behavior.
+
+Lives in ``superset_core`` so extensions can subclass it exactly the way the
+host's built-in widgets do — the same contract, no app-internal imports. A
+concrete widget registers itself as a whole with the ``@widget`` decorator (see
+``superset_core.widgets.decorators``).
+
+``Widget`` is the single registered unit for a widget type. Today it exposes the
+**control panel** (the JSON Schema that drives the Inspector and MCP, plus
+commit-time validation). It is deliberately the wrapper — not a bare
+"controls" object — because a widget type's other backend concerns will hang off
+the same class and register together: e.g. building a ``SemanticQuery`` from the
+control values, and post-processing the query results before they reach the
+viz. Those aren't relevant to the frontend, but they belong in the same registry
+entry, so they live here rather than in a parallel registry.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from pydantic import BaseModel, ValidationError
+
+# Reuse the Semantic Layer's schema helper: it turns a Pydantic model into a
+# JSON Schema, preserves field order, and blanks ``x-dynamic`` enums when no
+# configuration is supplied — the exact machinery the SIP proposes to share.
+from superset_core.semantic_layers.config import build_configuration_schema
+
+logger = logging.getLogger(__name__)
+
+
+class Widget:
+ """
+ Base class for a Dashboard V2 widget's backend behavior (the registered
+ unit for a widget type).
+
+ A concrete widget type is declared with the ``@widget`` decorator (which
+ sets ``widget_type``/``name``/``description``) and sets a ``controls_class``
+ (a Pydantic model). The model's JSON Schema *is* the control panel; it
+ describes the widget's ``node.props`` and is served to both the frontend and
+ MCP. Future backend concerns for a widget type (e.g. ``build_semantic_query``
+ from the control values, or a query-result handler) will be added here as
+ sibling methods so a widget is always registered as a whole.
+ """
+
+ widget_type: str
+ name: str
+ description: str = ""
+ controls_class: type[BaseModel]
+
+ @classmethod
+ def get_control_schema(
+ cls,
+ control_values: dict[str, Any] | None = None,
+ series: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """
+ Return the JSON Schema for this widget's controls.
+
+ ``control_values`` (the full current ``node.props``) is accepted so
+ dynamic fields can be enriched from it, mirroring the Semantic Layer.
+ ``series`` carries the distinct dimension values the frontend
+ discovered from the query results (they cannot come from
+ ``control_values`` alone, which hold the dimension *name*, not its
+ values). Partial or invalid values during editing are tolerated and
+ fall back to the base schema; enrichment errors propagate so the caller
+ can degrade gracefully.
+ """
+ parsed: BaseModel | None = None
+ if control_values:
+ try:
+ parsed = cls.controls_class.model_validate(control_values)
+ except Exception: # pylint: disable=broad-except
+ # Partial control values during editing are expected; fall back
+ # to the base schema.
+ logger.debug(
+ "Could not validate control values for %s; using base schema",
+ cls.widget_type,
+ exc_info=True,
+ )
+ schema = build_configuration_schema(cls.controls_class, parsed)
+ cls.enrich_schema(schema, parsed, series or [])
+ return schema
+
+ @classmethod
+ def enrich_schema(
+ cls,
+ schema: dict[str, Any],
+ parsed: BaseModel | None,
+ series: list[str],
+ ) -> None:
+ """
+ Hook to populate ``x-dynamic`` fields from the current control values
+ and the discovered ``series`` values. Default is a no-op; overridden by
+ widgets with dependent fields. Mutates ``schema`` in place.
+ """
+
+ @classmethod
+ def validate_control_values(
+ cls,
+ control_values: dict[str, Any] | None,
+ ) -> list[dict[str, Any]]:
+ """
+ Strictly validate ``control_values`` against ``controls_class``,
+ returning a list of ``{"loc", "message"}`` errors (empty when valid).
+
+ This is the **commit-time** gate, distinct from ``get_control_schema``
+ (which tolerates partial/invalid values so a form can be edited without
+ erroring). It runs the model's full validation — including cross-field
+ rules declared as Pydantic ``@model_validator`` / ``@field_validator``
+ on ``controls_class`` — so a caller (or an AI) gets an actionable
+ message instead of a silently-broken widget. It is widget-agnostic:
+ every rule lives declaratively on the model; this method just surfaces
+ whatever the model enforces.
+ """
+ if not control_values:
+ return []
+ try:
+ cls.controls_class.model_validate(control_values)
+ except ValidationError as ex:
+ return [
+ {
+ "loc": [str(part) for part in error.get("loc", ())],
+ "message": error.get("msg", ""),
+ }
+ for error in ex.errors()
+ ]
+ return []
diff --git a/superset-core/src/superset_core/widgets/decorators.py b/superset-core/src/superset_core/widgets/decorators.py
new file mode 100644
index 00000000000..527b2ba629e
--- /dev/null
+++ b/superset-core/src/superset_core/widgets/decorators.py
@@ -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.
+
+"""
+Widget registration decorator for Superset.
+
+This module provides the decorator an extension (or the host) uses to register
+a Dashboard V2 widget with the host application, enabling automatic discovery —
+the same contract built-in widgets use.
+
+Usage:
+ from superset_core.widgets.base import Widget
+ from superset_core.widgets.decorators import widget
+
+ @widget(
+ widget_type="my-widget",
+ name="My Widget",
+ description="...",
+ )
+ class MyWidget(Widget):
+ controls_class = MyWidgetControlsModel
+"""
+
+from __future__ import annotations
+
+from typing import Callable, TypeVar
+
+# Type variable for decorated widget classes
+T = TypeVar("T")
+
+
+def widget(
+ widget_type: str,
+ name: str,
+ description: str | None = None,
+) -> Callable[[T], T]:
+ """
+ Decorator to register a widget.
+
+ Automatically detects extension context and applies appropriate
+ namespacing to prevent ``widget_type`` conflicts between the host and
+ extension widgets, and raises on a genuine collision so one widget cannot
+ silently replace another's schema.
+
+ Host implementations replace this function during initialization with a
+ concrete implementation providing actual functionality.
+
+ Args:
+ widget_type: Unique widget type identifier (e.g., "metric-tile",
+ "balloons"), matching the dashboard node's ``type``. Used as the key
+ in the widget registry.
+ name: Human-readable display name (e.g., "Metric Tile"). Shown in the UI
+ when listing available widget types.
+ description: Optional description for documentation and UI tooltips.
+
+ Returns:
+ The decorated widget class, registered with the host application.
+
+ Raises:
+ NotImplementedError: If called before the host implementation is
+ initialized.
+
+ Example:
+ from superset_core.widgets.base import Widget
+ from superset_core.widgets.decorators import widget
+
+ @widget(
+ widget_type="my-widget",
+ name="My Widget",
+ description="A custom widget",
+ )
+ class MyWidget(Widget):
+ controls_class = MyWidgetControlsModel
+ """
+ raise NotImplementedError(
+ "Widget decorator not initialized. "
+ "This decorator should be replaced during Superset startup."
+ )
diff --git a/superset-frontend/packages/superset-core/README.md b/superset-frontend/packages/superset-core/README.md
index ed55c7ddf30..8ad10d4f5b4 100644
--- a/superset-frontend/packages/superset-core/README.md
+++ b/superset-frontend/packages/superset-core/README.md
@@ -22,7 +22,7 @@ under the License.
[](https://badge.fury.io/js/%40apache-superset%2Fcore)
[](https://opensource.org/licenses/Apache-2.0)
-The official core package for building Apache Superset extensions and integrations. This package provides essential building blocks including shared UI components, utility functions, APIs, and type definitions for both the host application and extensions.
+The official core package for building Apache Superset extensions and integrations. This package provides essential widgets including shared UI components, utility functions, APIs, and type definitions for both the host application and extensions.
## 📦 Installation
diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts
index e8cb136ff7c..82621a0dee9 100644
--- a/superset-frontend/packages/superset-core/src/dashboard/index.ts
+++ b/superset-frontend/packages/superset-core/src/dashboard/index.ts
@@ -26,7 +26,7 @@
* extension) or any other extension calls to place, move, resize, and
* remove nodes. This is an early sketch of the design doc's platform-API
* section, not yet backed by persistence, real chart execution, or the
- * `ChartPlugin`/building-block catalog — every method here is synchronous
+ * `ChartPlugin`/widget catalog — every method here is synchronous
* and in-memory.
*
* Deliberately granular, matching `sqlLab`'s own style (`getCurrentTab()`,
@@ -34,16 +34,16 @@
* from {@link getRoot}, walk down via each node's `children` and
* {@link getNode}, and re-query after {@link onDidLayoutChange} fires.
*
- * `dashboard` owns node placement and layout only. Block-instance content
+ * `dashboard` owns node placement and layout only. Widget-instance content
* (props/style/dataBinding) is intentionally out of scope here — it belongs
- * to a future `buildingBlocks` namespace mirroring this one.
+ * to a future `widgets` namespace mirroring this one.
*
* @example
* ```typescript
* import { dashboard } from '@apache-superset/core';
*
* const root = dashboard.getRoot();
- * const id = dashboard.addBuildingBlock(root.id, 0, {
+ * const id = dashboard.addWidget(root.id, 0, {
* type: 'text',
* props: { content: 'Hello dashboard' },
* });
@@ -92,13 +92,13 @@ export interface LayoutProps {
/**
* A single node in the dashboard tree. `canvas` and `text` are native
- * layout primitives; any other `type` is a building-block registry key (a
- * chart, metric tile, or extension-contributed block).
+ * layout primitives; any other `type` is a widget registry key (a
+ * chart, metric tile, or extension-contributed widget).
*
* `props`/`style` are inlined directly on the node for now. Once a
- * `buildingBlocks` content namespace exists, block-type nodes will instead
+ * `widgets` content namespace exists, widget-type nodes will instead
* carry a `ref` into it — matching the design doc's split between dashboard
- * layout and building-block content.
+ * layout and widget content.
*/
export interface DashboardNode {
id: string;
@@ -112,14 +112,14 @@ export interface DashboardNode {
* repositioning a node on the canvas never changes this array.
*/
children?: string[];
- /** Leaf/building-block nodes only — functional/content config. */
+ /** Leaf/widget nodes only — functional/content config. */
props?: Record<string, unknown>;
- /** Leaf/building-block nodes only — visual customization. */
+ /** Leaf/widget nodes only — visual customization. */
style?: Record<string, unknown>;
}
-/** Everything needed to create a new node, passed to {@link addBuildingBlock}. */
-export interface BuildingBlockSpec {
+/** Everything needed to create a new node, passed to {@link addWidget}. */
+export interface WidgetSpec {
type: string;
layout?: LayoutProps;
props?: Record<string, unknown>;
@@ -150,30 +150,30 @@ export declare function getNode(id: string): DashboardNode | undefined;
* @example
* ```typescript
* const root = dashboard.getRoot();
- * dashboard.addBuildingBlock(root.id, 0, {
+ * dashboard.addWidget(root.id, 0, {
* type: 'canvas',
* layout: { colSpan: 12, columns: 4, gap: 16 },
* });
* ```
*/
-export declare function addBuildingBlock(
+export declare function addWidget(
parentId: string,
index: number,
- spec: BuildingBlockSpec,
+ spec: WidgetSpec,
): string;
/**
* Removes a node and its entire subtree (if it's a `canvas`), detaching it
* from its parent. No-op if `id` doesn't exist. Throws if `id` is the root.
*/
-export declare function removeBuildingBlock(id: string): void;
+export declare function removeWidget(id: string): void;
/**
* Moves an existing node to a new `canvas` parent at `newIndex`, detaching
* it from wherever it currently sits. Throws if `newParentId` is `id` itself
* or one of its own descendants.
*/
-export declare function moveBuildingBlock(
+export declare function moveWidget(
id: string,
newParentId: string,
newIndex: number,
@@ -189,7 +189,7 @@ export declare function updateLayout(
/**
* Shallow-merges `props` into a node's existing props — the content-side
- * counterpart to {@link updateLayout}. Use this to edit an existing block
+ * counterpart to {@link updateLayout}. Use this to edit an existing widget
* in place (e.g. a chart's `dataBinding`/`echartsOptions`, or a markdown
* node's `content`) rather than removing and re-adding the node just to
* change what it renders, which loses its position, layout, and identity.
@@ -207,7 +207,7 @@ export declare function updateProps(
export declare const onDidLayoutChange: Event<void>;
/**
- * What an `echarts`-type building block queries. Deliberately generic (no
+ * What an `echarts`-type widget queries. Deliberately generic (no
* `viz_type`): {@link fetchQueryData} always hits the same code path
* Superset falls back to when a form_data's `viz_type` has no registered
* ChartPlugin, so it works for any chart shape without per-viz-type
@@ -233,7 +233,7 @@ export interface QueryDataResult {
* Runs an ad hoc query against a dataset and returns plain tabular rows.
* Rejects with a descriptive error (e.g. an unknown column/metric name) if
* the query is invalid — callers that create `echarts` nodes should await
- * this *before* calling {@link addBuildingBlock}, so a bad `dataBinding`
+ * this *before* calling {@link addWidget}, so a bad `dataBinding`
* surfaces as an immediate, correctable tool error instead of a node that
* silently fails to render later.
*/
diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts
index cbe26686075..f676bfc49b7 100644
--- a/superset-frontend/packages/superset-core/src/views/index.ts
+++ b/superset-frontend/packages/superset-core/src/views/index.ts
@@ -60,7 +60,7 @@ export interface View {
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param component The React component to render at that location. Most
* locations render it with no props; check the target location's own docs
- * for whether it passes any (e.g. "dashboard.buildingBlocks" passes
+ * for whether it passes any (e.g. "dashboard.widgets" passes
* `{ nodeId }`).
* @returns A Disposable that unregisters the view when disposed.
*
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
index aedcf5e240c..cf462b11b19 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
@@ -20,9 +20,9 @@ import DashboardProvider, { registerContainerType } from './DashboardProvider';
// A stand-in container type for exercising generic container mechanics
// (holding children, being a valid move/collision target) — 'canvas' itself
-// is no longer addable (it's reserved for the root, see `addBuildingBlock`),
+// is no longer addable (it's reserved for the root, see `addWidget`),
// and this suite tests `DashboardProvider` in isolation, without the
-// registration (`registerBuiltInBuildingBlocks`) that gives 'tabs'/'tab'
+// registration (`registerBuiltInWidgets`) that gives 'tabs'/'tab'
// their own container status.
const TEST_CONTAINER_TYPE = 'container';
@@ -53,121 +53,121 @@ test('getNode returns undefined for an unknown id', () => {
expect(DashboardProvider.getInstance().getNode('missing')).toBeUndefined();
});
-test('addBuildingBlock inserts a node into the parent at the given index', () => {
+test('addWidget inserts a node into the parent at the given index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const firstId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
- const secondId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ const firstId = provider.addWidget(rootId, 0, { type: 'text' });
+ const secondId = provider.addWidget(rootId, 0, { type: 'text' });
expect(provider.getRoot().children).toEqual([secondId, firstId]);
});
-test('addBuildingBlock clamps an out-of-range index', () => {
+test('addWidget clamps an out-of-range index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 99, { type: 'text' });
+ const id = provider.addWidget(rootId, 99, { type: 'text' });
expect(provider.getRoot().children).toEqual([id]);
});
-test('addBuildingBlock gives container nodes an empty children array', () => {
+test('addWidget gives container nodes an empty children array', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, {
+ const id = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
expect(provider.getNode(id)?.children).toEqual([]);
});
-test('addBuildingBlock throws for an unknown parent', () => {
+test('addWidget throws for an unknown parent', () => {
const provider = DashboardProvider.getInstance();
- expect(() =>
- provider.addBuildingBlock('missing', 0, { type: 'text' }),
- ).toThrow(/Unknown parent node/);
+ expect(() => provider.addWidget('missing', 0, { type: 'text' })).toThrow(
+ /Unknown parent node/,
+ );
});
-test('addBuildingBlock throws when the parent cannot hold children', () => {
+test('addWidget throws when the parent cannot hold children', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ const leafId = provider.addWidget(rootId, 0, { type: 'text' });
- expect(() => provider.addBuildingBlock(leafId, 0, { type: 'text' })).toThrow(
+ expect(() => provider.addWidget(leafId, 0, { type: 'text' })).toThrow(
/not a container/,
);
});
-test('removeBuildingBlock detaches the node from its parent', () => {
+test('removeWidget detaches the node from its parent', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ const id = provider.addWidget(rootId, 0, { type: 'text' });
- provider.removeBuildingBlock(id);
+ provider.removeWidget(id);
expect(provider.getNode(id)).toBeUndefined();
expect(provider.getRoot().children).toEqual([]);
});
-test('removeBuildingBlock removes an entire container subtree', () => {
+test('removeWidget removes an entire container subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const containerId = provider.addBuildingBlock(rootId, 0, {
+ const containerId = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
- const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
+ const childId = provider.addWidget(containerId, 0, { type: 'text' });
- provider.removeBuildingBlock(containerId);
+ provider.removeWidget(containerId);
expect(provider.getNode(containerId)).toBeUndefined();
expect(provider.getNode(childId)).toBeUndefined();
});
-test('removeBuildingBlock throws for the root node', () => {
+test('removeWidget throws for the root node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- expect(() => provider.removeBuildingBlock(rootId)).toThrow(
+ expect(() => provider.removeWidget(rootId)).toThrow(
/Cannot remove the root node/,
);
});
-test('removeBuildingBlock is a no-op for an unknown id', () => {
+test('removeWidget is a no-op for an unknown id', () => {
const provider = DashboardProvider.getInstance();
const revisionBefore = provider.getRevision();
- provider.removeBuildingBlock('missing');
+ provider.removeWidget('missing');
expect(provider.getRevision()).toBe(revisionBefore);
});
-test('moveBuildingBlock relocates a node to a new parent at the given index', () => {
+test('moveWidget relocates a node to a new parent at the given index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const containerId = provider.addBuildingBlock(rootId, 0, {
+ const containerId = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
- const id = provider.addBuildingBlock(rootId, 1, { type: 'text' });
+ const id = provider.addWidget(rootId, 1, { type: 'text' });
- provider.moveBuildingBlock(id, containerId, 0);
+ provider.moveWidget(id, containerId, 0);
expect(provider.getRoot().children).toEqual([containerId]);
expect(provider.getNode(containerId)?.children).toEqual([id]);
});
-test('moveBuildingBlock keeps an explicit position when the parent is unchanged', () => {
+test('moveWidget keeps an explicit position when the parent is unchanged', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const first = provider.addBuildingBlock(rootId, 0, { type: 'text' });
- const second = provider.addBuildingBlock(rootId, 1, { type: 'text' });
+ const first = provider.addWidget(rootId, 0, { type: 'text' });
+ const second = provider.addWidget(rootId, 1, { type: 'text' });
provider.updateLayout(first, { col: 3, row: 2, colSpan: 6 });
// A move within one parent reorders reading/DOM/tab order alone — the
// node's own placement is not part of what changed.
- provider.moveBuildingBlock(first, rootId, 1);
+ provider.moveWidget(first, rootId, 1);
expect(provider.getRoot().children).toEqual([second, first]);
expect(provider.getNode(first)?.layout).toMatchObject({
@@ -180,10 +180,10 @@ test('moveBuildingBlock keeps an explicit position when the parent is unchanged'
test('getParentId returns the container holding a node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const containerId = provider.addBuildingBlock(rootId, 0, {
+ const containerId = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
- const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
+ const childId = provider.addWidget(containerId, 0, { type: 'text' });
expect(provider.getParentId(childId)).toBe(containerId);
expect(provider.getParentId(containerId)).toBe(rootId);
@@ -196,48 +196,48 @@ test('getParentId returns undefined for the root and for an unknown id', () => {
expect(provider.getParentId('missing')).toBeUndefined();
});
-test('moveBuildingBlock throws when moving the root node', () => {
+test('moveWidget throws when moving the root node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const containerId = provider.addBuildingBlock(rootId, 0, {
+ const containerId = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
- expect(() => provider.moveBuildingBlock(rootId, containerId, 0)).toThrow(
+ expect(() => provider.moveWidget(rootId, containerId, 0)).toThrow(
/Cannot move the root node/,
);
});
-test('moveBuildingBlock throws when the target cannot hold children', () => {
+test('moveWidget throws when the target cannot hold children', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
- const otherId = provider.addBuildingBlock(rootId, 1, { type: 'text' });
+ const leafId = provider.addWidget(rootId, 0, { type: 'text' });
+ const otherId = provider.addWidget(rootId, 1, { type: 'text' });
- expect(() => provider.moveBuildingBlock(otherId, leafId, 0)).toThrow(
+ expect(() => provider.moveWidget(otherId, leafId, 0)).toThrow(
/not a container/,
);
});
-test('moveBuildingBlock throws when moving a node into its own subtree', () => {
+test('moveWidget throws when moving a node into its own subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const containerId = provider.addBuildingBlock(rootId, 0, {
+ const containerId = provider.addWidget(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
- const childContainerId = provider.addBuildingBlock(containerId, 0, {
+ const childContainerId = provider.addWidget(containerId, 0, {
type: TEST_CONTAINER_TYPE,
});
- expect(() =>
- provider.moveBuildingBlock(containerId, childContainerId, 0),
- ).toThrow(/into itself or one of its own descendants/);
+ expect(() => provider.moveWidget(containerId, childContainerId, 0)).toThrow(
+ /into itself or one of its own descendants/,
+ );
});
test("updateLayout merges into the node's existing layout", () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, {
+ const id = provider.addWidget(rootId, 0, {
type: 'text',
layout: { colSpan: 12 },
});
@@ -261,11 +261,11 @@ test('updateLayout throws for an unknown node', () => {
test('updateLayout displaces an explicitly placed sibling it now collides with', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const firstId = provider.addBuildingBlock(rootId, 0, {
+ const firstId = provider.addWidget(rootId, 0, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
- const secondId = provider.addBuildingBlock(rootId, 1, {
+ const secondId = provider.addWidget(rootId, 1, {
type: 'text',
layout: { col: 1, row: 2, colSpan: 24 },
});
@@ -289,10 +289,10 @@ test('updateLayout displaces an explicitly placed sibling it now collides with',
});
});
-test('addBuildingBlock displaces the new node when it collides with an earlier explicit sibling', () => {
+test('addWidget displaces the new node when it collides with an earlier explicit sibling', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const existingId = provider.addBuildingBlock(rootId, 0, {
+ const existingId = provider.addWidget(rootId, 0, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
@@ -301,7 +301,7 @@ test('addBuildingBlock displaces the new node when it collides with an earlier e
// `resolveExplicitCollisions` uses (earlier in `children` order keeps its
// declared position) — it's the new node that gets pushed down, not the
// one already there.
- const newId = provider.addBuildingBlock(rootId, 1, {
+ const newId = provider.addWidget(rootId, 1, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
@@ -329,11 +329,11 @@ test('updateLayout does not resolve collisions for a node with no parent (the ro
test('updateLayouts merges a layout update into each node in a single commit', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const firstId = provider.addBuildingBlock(rootId, 0, {
+ const firstId = provider.addWidget(rootId, 0, {
type: 'text',
layout: { colSpan: 6 },
});
- const secondId = provider.addBuildingBlock(rootId, 1, {
+ const secondId = provider.addWidget(rootId, 1, {
type: 'text',
layout: { colSpan: 6 },
});
@@ -360,7 +360,7 @@ test('updateLayouts merges a layout update into each node in a single commit', (
test('updateLayouts silently skips an unknown node id', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ const id = provider.addWidget(rootId, 0, { type: 'text' });
expect(() =>
provider.updateLayouts({ missing: { col: 1 }, [id]: { col: 2 } }),
@@ -374,7 +374,7 @@ test('onDidLayoutChange fires on every mutation', () => {
const listener = jest.fn();
const disposable = provider.onDidLayoutChange(listener);
- provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ provider.addWidget(rootId, 0, { type: 'text' });
expect(listener).toHaveBeenCalledTimes(1);
disposable.dispose();
@@ -387,7 +387,7 @@ test('getRevision increments on every mutation and is stable otherwise', () => {
expect(provider.getRevision()).toBe(before);
- provider.addBuildingBlock(rootId, 0, { type: 'text' });
+ provider.addWidget(rootId, 0, { type: 'text' });
expect(provider.getRevision()).toBe(before + 1);
});
@@ -398,7 +398,7 @@ test('getRevision increments on every mutation and is stable otherwise', () => {
*/
test('selecting a node reports it back', () => {
const provider = DashboardProvider.getInstance();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
});
@@ -409,28 +409,28 @@ test('selecting a node reports it back', () => {
test('removing the selected node clears the selection', () => {
const provider = DashboardProvider.getInstance();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
});
provider.setSelection(id);
- provider.removeBuildingBlock(id);
+ provider.removeWidget(id);
// A selection is a reference to a node, and a node that is gone cannot be
// the thing being edited — an inspector reading a dangling id would show a
- // block that no longer exists.
+ // widget that no longer exists.
expect(provider.getSelection()).toBeUndefined();
});
test('removing a container clears a selection inside its subtree', () => {
const provider = DashboardProvider.getInstance();
- const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const sectionId = provider.addWidget(provider.getRoot().id, 0, {
type: TEST_CONTAINER_TYPE,
});
- const childId = provider.addBuildingBlock(sectionId, 0, { type: 'markdown' });
+ const childId = provider.addWidget(sectionId, 0, { type: 'markdown' });
provider.setSelection(childId);
- provider.removeBuildingBlock(sectionId);
+ provider.removeWidget(sectionId);
// The node that vanished was a descendant of the one actually removed,
// which is why the check belongs in the commit rather than at the removal.
@@ -439,7 +439,7 @@ test('removing a container clears a selection inside its subtree', () => {
test('reset clears the selection along with the tree', () => {
const provider = DashboardProvider.getInstance();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
});
provider.setSelection(id);
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.ts b/superset-frontend/src/core/dashboard/DashboardProvider.ts
index 7835d93f2b8..cbd74650a10 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.ts
@@ -23,7 +23,7 @@ import { DEFAULT_COLUMNS } from './layoutStyle';
import { resolveExplicitCollisions } from './gridPacking';
type DashboardNode = dashboardApi.DashboardNode;
-type BuildingBlockSpec = dashboardApi.BuildingBlockSpec;
+type WidgetSpec = dashboardApi.WidgetSpec;
type LayoutProps = dashboardApi.LayoutProps;
/** Node data as stored internally — same as the public `DashboardNode`, minus `id` (the map key already is the id). */
@@ -38,14 +38,14 @@ const ROOT_ID = 'root';
* comparison of its own: this provider, deciding whether a new node gets a
* `children` array at all, and the palette, deciding that placing this
* specific type is not an authored feature (it's reserved for the root).
- * Rendered by `Grid` — see `BuildingBlockView`, which resolves the root's
- * renderer to it directly rather than through the building-block registry.
+ * Rendered by `Grid` — see `WidgetView`, which resolves the root's
+ * renderer to it directly rather than through the widget registry.
*/
export const GRID_TYPE = 'grid';
/**
* Container types beyond the root's own — each registered by whatever adds
- * that building block (see `registerBuiltInBuildingBlocks`), the same way a
+ * that widget (see `registerBuiltInWidgets`), the same way a
* type registers its renderer. A container's own arrangement of its
* children is that type's business, not this provider's (see the
* composition/layout design doc) — this set only ever answers the one
@@ -112,7 +112,7 @@ class DashboardProvider {
* It lives here rather than in page state because the canvas draws it and
* the editor panel reads it, and those sit in different layers — putting it
* in the one place both already subscribe to beats threading it through the
- * render tree that `BuildingBlockView` deliberately keeps ignorant.
+ * render tree that `WidgetView` deliberately keeps ignorant.
*/
private selection: string | undefined;
@@ -178,7 +178,7 @@ class DashboardProvider {
* The container a node sits in, or `undefined` for the root and for a
* node that is not in the tree.
*
- * {@link moveBuildingBlock} takes the destination parent as an argument, so
+ * {@link moveWidget} takes the destination parent as an argument, so
* every caller that moves a node already has to know which parent it is in
* — a caller reordering a node within its own container most of all. The
* walk itself is one line, and leaving it out meant each caller wrote that
@@ -209,7 +209,7 @@ class DashboardProvider {
/**
* Displaces any of `parentId`'s explicitly placed children that now
* collide with one another (see {@link resolveExplicitCollisions}) and
- * folds the result into `nodes`. `addBuildingBlock`/`updateLayout` are the
+ * folds the result into `nodes`. `addWidget`/`updateLayout` are the
* two ways an extension's AI tools place a node without going through
* `RootGrid`'s interactive drag/resize at all — this gives that
* programmatic path the same "nothing ends up stuck overlapping"
@@ -245,14 +245,10 @@ class DashboardProvider {
return result;
}
- public addBuildingBlock(
- parentId: string,
- index: number,
- spec: BuildingBlockSpec,
- ): string {
+ public addWidget(parentId: string, index: number, spec: WidgetSpec): string {
if (spec.type === GRID_TYPE) {
throw new Error(
- `[dashboard] Cannot add a "${GRID_TYPE}" node — it is reserved for the dashboard root, not a Building Block`,
+ `[dashboard] Cannot add a "${GRID_TYPE}" node — it is reserved for the dashboard root, not a Widget`,
);
}
@@ -290,7 +286,7 @@ class DashboardProvider {
return id;
}
- public removeBuildingBlock(id: string): void {
+ public removeWidget(id: string): void {
if (id === ROOT_ID) {
throw new Error('[dashboard] Cannot remove the root node');
}
@@ -315,11 +311,7 @@ class DashboardProvider {
this.commit(nodes);
}
- public moveBuildingBlock(
- id: string,
- newParentId: string,
- newIndex: number,
- ): void {
+ public moveWidget(id: string, newParentId: string, newIndex: number): void {
if (id === ROOT_ID) {
throw new Error('[dashboard] Cannot move the root node');
}
@@ -366,7 +358,7 @@ class DashboardProvider {
// None of which is true when the parent has not changed. A move within
// one container is a reorder of reading/DOM/tab order alone, and the
// position it keeps is the one the author placed it at. Resetting it
- // would teleport the block to auto-placement as the price of a reorder.
+ // would teleport the widget to auto-placement as the price of a reorder.
if (oldParentId !== newParentId) {
const node = nodes[id];
const destColumns = targetParent.layout?.columns ?? DEFAULT_COLUMNS;
@@ -380,6 +372,12 @@ class DashboardProvider {
node.layout?.colSpan != null
? Math.min(node.layout.colSpan, destColumns)
: undefined,
+ // `rowSpan` is unit-dependent on the container type — row tracks in
+ // the root grid, pixels in a flow container (see `flowContent`). A
+ // value meaningful in the old parent would be misread in the new one
+ // (e.g. `8` tracks becoming 8px), so drop it and let the destination
+ // apply its own default.
+ rowSpan: undefined,
},
};
}
@@ -426,7 +424,7 @@ class DashboardProvider {
/**
* Shallow-merges `props` into a node's existing props — the content-side
* counterpart to {@link updateLayout}. Lets a chart's `echartsOptions`
- * (or a markdown block's `content`) be edited in place, instead of the
+ * (or a markdown widget's `content`) be edited in place, instead of the
* only alternative being remove + re-add, which loses the node's
* position, layout, and identity just to change what it renders.
*/
diff --git a/superset-frontend/src/core/dashboard/RootGrid.test.tsx b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
index 62534c59e2e..e813e094846 100644
--- a/superset-frontend/src/core/dashboard/RootGrid.test.tsx
+++ b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
@@ -19,14 +19,14 @@
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from './DashboardProvider';
import RootGrid from './RootGrid';
-import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
+import { registerBuiltInWidgets } from './registerBuiltInWidgets';
import {
__getLastGridStackInstance,
__resetGridStackMock,
} from '../../../spec/__mocks__/gridstackMock';
beforeAll(() => {
- registerBuiltInBuildingBlocks();
+ registerBuiltInWidgets();
});
/**
@@ -68,15 +68,15 @@ afterEach(() => {
const mount = () => {
const rootId = provider.getRoot().id;
- const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
- const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
+ const first = provider.addWidget(rootId, 0, { type: 'markdown' });
+ const second = provider.addWidget(rootId, 1, { type: 'markdown' });
render(<RootGrid nodeId={rootId} />);
return { rootId, first, second };
};
/** A drag payload jsdom's synthetic events do not carry on their own. */
const paletteTransfer = (type: string) => {
- const data = new Map([['application/x-dashboard-building-block', type]]);
+ const data = new Map([['application/x-dashboard-widget', type]]);
return {
types: [...data.keys()],
getData: (key: string) => data.get(key) ?? '',
@@ -93,7 +93,7 @@ test('the grid initializes with the root layout mapped onto GridStack options',
expect(grid?.options).toMatchObject({
column: 24,
// 48/8: rowUnitPx(32) + gap(16), and gap(16)/2 — pinned explicitly since
- // getting either wrong silently regresses every block's own height.
+ // getting either wrong silently regresses every widget's own height.
cellHeight: 48,
margin: 8,
// Collision avoidance is always on regardless of this; `float: true`
@@ -114,7 +114,7 @@ test('the grid initializes with the root layout mapped onto GridStack options',
});
});
-test('a block resizes from all four corners', () => {
+test('a widget resizes from all four corners', () => {
mount();
const grid = __getLastGridStackInstance();
@@ -126,14 +126,14 @@ test('the grid is told not to start a drag from the remove control, a nested con
// GridStack's own draggable engine matches this selector up the ancestors
// of whatever was pressed — aiming at the bin would otherwise drag the
- // block it is attached to, and dragging a chart out of a `tabs` block
- // would instead drag the whole `tabs` block on the root grid.
+ // widget it is attached to, and dragging a chart out of a `tabs` widget
+ // would instead drag the whole `tabs` widget on the root grid.
const grid = __getLastGridStackInstance()!;
const { cancel } = grid.options.draggable as { cancel: string };
- expect(cancel).toContain('[data-block-remove]');
+ expect(cancel).toContain('[data-widget-remove]');
expect(cancel).toContain('[data-container-id]');
- expect(cancel).toContain('[data-block-resize]');
- expect(cancel).toContain('[data-block-header-control]');
+ expect(cancel).toContain('[data-widget-resize]');
+ expect(cancel).toContain('[data-widget-header-control]');
});
test('every child is registered with GridStack at its packed position', () => {
@@ -194,7 +194,7 @@ test('ending a drag over a nested container reparents instead of committing a la
const { rootId, first } = mount();
let collapsibleId = '';
act(() => {
- collapsibleId = provider.addBuildingBlock(rootId, 1, {
+ collapsibleId = provider.addWidget(rootId, 1, {
type: 'collapsible',
});
});
@@ -227,7 +227,7 @@ test('ending a drag over its own container commits a layout instead of reparenti
el.gridstackNode = { id: first, x: 3, y: 1, w: 4, h: 2 };
// The root grid's own surface also carries `data-container-id` — landing
- // back on the container the block already belongs to must not be read as
+ // back on the container the widget already belongs to must not be read as
// a reparent onto itself.
(document.elementFromPoint as jest.Mock).mockReturnValue(
screen.getByTestId('grid-container'),
@@ -244,7 +244,7 @@ test('ending a drag over its own container commits a layout instead of reparenti
});
});
-test('dropping a palette block on the grid places it at the resolved cell', () => {
+test('dropping a palette widget on the grid places it at the resolved cell', () => {
const { rootId } = mount();
fireEvent.dragOver(
@@ -270,7 +270,7 @@ test('dropping a palette block on the grid places it at the resolved cell', () =
expect(provider.getNode(children[2])?.type).toBe('markdown');
});
-test('dropping a palette block past the grid’s own rendered rows appends it at the end', () => {
+test('dropping a palette widget past the grid’s own rendered rows appends it at the end', () => {
const { rootId } = mount();
fireEvent.drop(screen.getByTestId('grid-container'), {
@@ -282,7 +282,7 @@ test('dropping a palette block past the grid’s own rendered rows appends it at
expect(provider.getNode(children[2])?.type).toBe('markdown');
});
-test('a drop carrying something else is not read as a block', () => {
+test('a drop carrying something else is not read as a widget', () => {
const { rootId } = mount();
const before = provider.getNode(rootId)?.children?.length;
@@ -296,25 +296,25 @@ test('a drop carrying something else is not read as a block', () => {
});
// A private type rather than text/plain is what keeps a dragged file, or a
- // selection of text from another window, from placing a block.
+ // selection of text from another window, from placing a widget.
expect(provider.getNode(rootId)?.children?.length).toBe(before);
});
-test('a placed block offers a way to remove it, and the root does not', () => {
+test('a placed widget offers a way to remove it, and the root does not', () => {
const { rootId, first } = mount();
- expect(screen.getByTestId(`block-remove-${first}`)).toBeInTheDocument();
+ expect(screen.getByTestId(`widget-remove-${first}`)).toBeInTheDocument();
// Removing the root is refused by the provider, so offering the button
// would be offering an error.
expect(
- screen.queryByTestId(`block-remove-${rootId}`),
+ screen.queryByTestId(`widget-remove-${rootId}`),
).not.toBeInTheDocument();
});
-test('the remove control removes that block and nothing else', () => {
+test('the remove control removes that widget and nothing else', () => {
const { rootId, first, second } = mount();
- fireEvent.click(screen.getByTestId(`block-remove-${first}`));
+ fireEvent.click(screen.getByTestId(`widget-remove-${first}`));
expect(provider.getNode(rootId)?.children).toEqual([second]);
});
@@ -322,19 +322,19 @@ test('the remove control removes that block and nothing else', () => {
test('clicking the remove control removes rather than selects', () => {
const { first, second } = mount();
- fireEvent.click(screen.getByTestId(`block-remove-${second}`));
+ fireEvent.click(screen.getByTestId(`widget-remove-${second}`));
// The wrapper selects on click and the button sits inside it. Without the
- // stop, removing a block would also try to select the thing just removed.
+ // stop, removing a widget would also try to select the thing just removed.
expect(provider.getSelection()).toBeUndefined();
expect(provider.getNode(second)).toBeUndefined();
expect(provider.getNode(first)).toBeDefined();
});
-test('unmounting a block removes its widget from GridStack without touching the DOM', () => {
+test('unmounting a widget removes its widget from GridStack without touching the DOM', () => {
const { rootId, first } = mount();
- act(() => provider.removeBuildingBlock(first));
+ act(() => provider.removeWidget(first));
const grid = __getLastGridStackInstance();
expect(grid?.removeWidget).toHaveBeenCalled();
diff --git a/superset-frontend/src/core/dashboard/RootGrid.tsx b/superset-frontend/src/core/dashboard/RootGrid.tsx
index c3c6af2147b..5777d8394f4 100644
--- a/superset-frontend/src/core/dashboard/RootGrid.tsx
+++ b/superset-frontend/src/core/dashboard/RootGrid.tsx
@@ -39,16 +39,16 @@ import {
} from './placement';
import { useGridStack } from './useGridStack';
import type { GestureEnd, GestureEndItem } from './useGridStack';
-import BuildingBlockView from './BuildingBlockView';
+import WidgetView from './WidgetView';
type LayoutProps = dashboardApi.LayoutProps;
/**
* The `draggableCancel`-equivalent guard: regions a press must never start a
* drag from. `[data-container-id]` is a nested container (dragging one chart
- * out of a `tabs` block must not drag the whole `tabs` block); `[data-block-
- * remove]` is a block's own remove control; `[data-block-resize]` is a flowed
- * block's own resize grip (`flowContent.tsx`); `[data-block-header-control]`
+ * out of a `tabs` widget must not drag the whole `tabs` widget); `[data-widget-
+ * remove]` is a widget's own remove control; `[data-widget-resize]` is a flowed
+ * widget's own resize grip (`flowContent.tsx`); `[data-widget-header-control]`
* is a type's own extra header control (e.g. `collapsible`'s toggle).
*
* `nodeId` is threaded through and excluded from the `data-container-id`
@@ -66,7 +66,7 @@ type LayoutProps = dashboardApi.LayoutProps;
* still matches and still cancels, exactly as intended.
*/
function cancelSelectorFor(nodeId: string): string {
- return `[data-container-id]:not([data-container-id="${CSS.escape(nodeId)}"]),[data-block-remove],[data-block-resize],[data-block-header-control]`;
+ return `[data-container-id]:not([data-container-id="${CSS.escape(nodeId)}"]),[data-widget-remove],[data-widget-resize],[data-widget-header-control]`;
}
/**
@@ -92,7 +92,7 @@ function cancelSelectorFor(nodeId: string): string {
* keeps the drop target's own floor intact when content is shorter.
*
* `.grid-stack-item-content`'s own default CSS gives it `overflow-y: auto`
- * — undone here, since `BuildingBlockView`'s own card already decides how
+ * — undone here, since `WidgetView`'s own card already decides how
* its content overflows (clipped, per its own `overflow: hidden`), and a
* second, independent scroll container around it would fight that rather
* than help it.
@@ -154,7 +154,7 @@ const DropGhost = styled.div`
/**
* Finds the deepest dashboard container actually under a screen point,
- * ignoring `excludeEl`'s own subtree — the block being dragged might itself
+ * ignoring `excludeEl`'s own subtree — the widget being dragged might itself
* be, or contain, a container, and a drop "onto itself" isn't a valid
* reparent target. Every container's outer element carries
* `data-container-id` (this component's own does; others set it on whatever
@@ -191,9 +191,9 @@ function rectsEqual(a: PackedRect, b: PackedRect): boolean {
/**
* One child, registered with GridStack via the two-level DOM structure its
* own CSS requires (`.grid-stack-item` > `.grid-stack-item-content`) —
- * `BuildingBlockView` itself needs no special handling for this, since it
+ * `WidgetView` itself needs no special handling for this, since it
* only ever has to fill 100% of whatever box it's given, the same as it
- * already does for a flowed block (`flowContent.tsx`'s `FlowItem`).
+ * already does for a flowed widget (`flowContent.tsx`'s `FlowItem`).
*
* `useLayoutEffect`, not `useEffect`: cleanup has to run, and
* `unregisterItem` has to call `removeWidget`, before React detaches the
@@ -230,29 +230,26 @@ function GridStackItem({
return (
<div ref={elRef} className="grid-stack-item">
<div className="grid-stack-item-content">
- <BuildingBlockView
- nodeId={nodeId}
- style={{ width: '100%', height: '100%' }}
- />
+ <WidgetView nodeId={nodeId} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
/**
- * The dashboard's own grid — not a Building Block (see the composition/
- * layout design doc), which is why it lives here rather than in `blocks/`
+ * The dashboard's own grid — not a Widget (see the composition/
+ * layout design doc), which is why it lives here rather than in `widgets/`
* alongside the things that get placed on it. There is exactly one of these
- * per dashboard, rendered for the root and only the root: `BuildingBlockView`
+ * per dashboard, rendered for the root and only the root: `WidgetView`
* resolves the root's renderer to this component directly, rather than
- * through the `dashboard.buildingBlocks` registry every real building block
+ * through the `dashboard.widgets` registry every real widget
* goes through — nothing places a `RootGrid`, and nothing ever will, the same
* way nothing places the dashboard itself.
*
* Backed by GridStack (see `useGridStack`, the only place that package is
* imported). All position/size math — including collision handling — is
- * GridStack's: resizing a block never shrinks a sibling, only displaces it
- * to the next open slot, which leaves every block's own authored (hand- or
+ * GridStack's: resizing a widget never shrinks a sibling, only displaces it
+ * to the next open slot, which leaves every widget's own authored (hand- or
* AI-set) span untouched — except a left/right *drop*, the one deliberate
* exception, which does shrink a sibling (see `resolveDropPlacement`).
* This component's job is translating between the stored
@@ -267,7 +264,7 @@ function GridStackItem({
* see `dataTransfer`, so this draws its own drop preview (`DropGhost`)
* instead of handing the gesture to GridStack at all.
*
- * Dragging a block into a *different* container (reparenting, as opposed to
+ * Dragging a widget into a *different* container (reparenting, as opposed to
* repositioning within this one) is handled by hit-testing which
* `data-container-id` is under the pointer when the drag ends — deliberately
* not something GridStack (scoped to one grid instance) handles on its own.
@@ -289,10 +286,10 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
// here (see this component's own doc comment on the identical check the
// previous, react-grid-layout-backed version of this made).
const [ghostRect, setGhostRect] = useState<PackedRect | null>(null);
- // The existing block's own *shrunk* half, while the cursor is hovering the
+ // The existing widget's own *shrunk* half, while the cursor is hovering the
// left/right split band of it — `resolveDropPlacement`'s `shrink`. Not a
// second placeholder box: it feeds `previewPacked` (below, where
- // `useGridStack` is called), which makes the *real* block visibly resize
+ // `useGridStack` is called), which makes the *real* widget visibly resize
// to this rect for as long as the hover lasts — the same way a live drag
// or resize elsewhere on this grid is never represented by a stand-in box
// either. Kept as its own piece of state (rather than folded into
@@ -348,12 +345,12 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
// What `useGridStack` actually renders — `packed` with the split target's
// own entry substituted for its shrunk-to half while `shrinkPreview` is
- // set. This is the real block visibly resizing to preview the split, not
+ // set. This is the real widget visibly resizing to preview the split, not
// a second placeholder box drawn over it: `useGridStack`'s own sync
// effect diffs this against GridStack's current node and calls `update`
// on it exactly the way a committed resize would, then diffs right back
// to `packed` the moment `shrinkPreview` clears (hover moves on, or the
- // drag ends), snapping the real block back to its actual current size.
+ // drag ends), snapping the real widget back to its actual current size.
// `resolveGridDropPlacement`/hit-testing below still reads the real,
// unmodified `packed` — resolving a placement against an already-shrunk
// neighbor would misread where its true edges are.
@@ -382,7 +379,7 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
* The two differ only in *how* they get the point and *which* occupancy
* map to ask against — a reposition excludes the item being dragged from
* it (it is the thing about to get a new position, not a fixed point to
- * test anyone else against); a fresh palette block was never in `packed`
+ * test anyone else against); a fresh palette widget was never in `packed`
* to begin with, so the plain, unmodified map is already correct for it.
*/
const resolvePlacementAtPoint = (
@@ -425,7 +422,7 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
);
/**
- * Where a palette drag actually lands, or where an existing block's own
+ * Where a palette drag actually lands, or where an existing widget's own
* drag/resize settles — the single sink both gestures write through,
* mirroring `handleGridDrop`/`handleExternalDrop`'s own "one path, so
* preview and outcome can't quietly diverge" reasoning.
@@ -436,7 +433,7 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
*
* 1. A reparent — the dragged element's own bounding-rect centre is
* hit-tested for a `data-container-id` other than this one. Landing on
- * itself or one of its own descendants throws (`moveBuildingBlock`'s
+ * itself or one of its own descendants throws (`moveWidget`'s
* own guard); that's caught and falls through to the checks below,
* same as any other drag.
* 2. A split — the cursor's own final position (`gesture.clientX/clientY`,
@@ -447,7 +444,7 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
* it is the thing about to get a new position, not a fixed point to
* test anyone else against). Landing on a sibling's left/right split
* band shrinks that sibling and resizes the dragged item into the other
- * half, in one commit — dragging an existing block onto another's half
+ * half, in one commit — dragging an existing widget onto another's half
* is meant to read as the identical gesture a palette drop landing
* there already is, not a lesser version of it that only ever pushes
* things down instead.
@@ -465,12 +462,12 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
);
if (targetContainerId && targetContainerId !== nodeId) {
try {
- // moveBuildingBlock itself clears col/row and clamps colSpan to
+ // moveWidget itself clears col/row and clamps colSpan to
// the destination's own column count — old coordinates were only
// ever meaningful in *this* container's grid.
const destIndex =
provider.getNode(targetContainerId)?.children?.length ?? 0;
- provider.moveBuildingBlock(gesture.id, targetContainerId, destIndex);
+ provider.moveWidget(gesture.id, targetContainerId, destIndex);
return;
} catch {
// Dropped onto itself or one of its own descendants — not a valid
@@ -533,16 +530,16 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
* area — the counterpart to `handleGridDrop`, below, which resolves the
* actual drop from the exact same `resolveGridDropPlacement` call so what
* an author sees while hovering is provably what they get on release.
- * Drives both `ghostRect` (the new block's own preview) and
- * `shrinkPreview` (the existing block's shrunk half, only set while
+ * Drives both `ghostRect` (the new widget's own preview) and
+ * `shrinkPreview` (the existing widget's shrunk half, only set while
* hovering a left/right split band — see `resolveDropPlacement`'s own
* doc comment for the other two bands, which never set it).
*
* Hovering over a *nested* container (a `tabs`/`collapsible`/`carousel`
- * block sitting on this grid, or anything a third party contributes)
+ * widget sitting on this grid, or anything a third party contributes)
* clears both previews instead: that container has its own drop target
* (`FlowContent`/`EmptyArea`, tagged `data-container-id`, the same way
- * this component's own `GridSurface` is) and is where the block actually
+ * this component's own `GridSurface` is) and is where the widget actually
* belongs, not beside or on top of the container's own card on *this*
* grid. Without this, both would react to the same hover, and only one of
* them ever runs its own cleanup on drop.
@@ -607,9 +604,9 @@ export default function RootGrid({ nodeId }: { nodeId: string }) {
* end").
*
* A `shrink` result commits in two calls, shrink *first* — see
- * `DashboardProvider.addBuildingBlock`'s own call to
+ * `DashboardProvider.addWidget`'s own call to
* `resolveParentCollisions`: with the neighbor already shrunk, the new
- * block never overlaps anything when that collision pass runs, so nothing
+ * widget never overlaps anything when that collision pass runs, so nothing
* is disturbed. Inserting first would overlap the still-wide neighbor and
* get pushed straight down by that same collision rule instead, which
* would permanently destroy the split before it ever rendered. All four
diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/WidgetView.test.tsx
similarity index 65%
rename from superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
rename to superset-frontend/src/core/dashboard/WidgetView.test.tsx
index 3686f18351b..923994b9eb7 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
+++ b/superset-frontend/src/core/dashboard/WidgetView.test.tsx
@@ -18,13 +18,13 @@
*/
import { render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from './DashboardProvider';
-import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
-import BuildingBlockView from './BuildingBlockView';
+import { registerBuiltInWidgets } from './registerBuiltInWidgets';
+import WidgetView from './WidgetView';
const provider = DashboardProvider.getInstance();
beforeAll(() => {
- registerBuiltInBuildingBlocks();
+ registerBuiltInWidgets();
});
beforeEach(() => {
@@ -33,20 +33,20 @@ beforeEach(() => {
const withBlock = () => {
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, {
+ const id = provider.addWidget(rootId, 0, {
type: 'metric-tile',
props: { label: 'Quarterly notes' },
});
- render(<BuildingBlockView nodeId={id} />);
+ render(<WidgetView nodeId={id} />);
return { rootId, id };
};
-test('a block says which one it is', () => {
+test('a widget says which one it is', () => {
const { id } = withBlock();
- // Named by the same call the Outline names its rows by, so a block is not
+ // Named by the same call the Outline names its rows by, so a widget is not
// "Quarterly notes" in one place and "Metric Tile" in the other.
- expect(screen.getByTestId(`block-title-${id}`)).toHaveTextContent(
+ expect(screen.getByTestId(`widget-title-${id}`)).toHaveTextContent(
'Quarterly notes',
);
});
@@ -56,42 +56,42 @@ test('the delete control does not have to be found first', () => {
// It used to appear only on hover, which is a control you have to already
// know is there. `toBeVisible` fails on the opacity that hid it.
- expect(screen.getByTestId(`block-remove-${id}`)).toBeVisible();
+ expect(screen.getByTestId(`widget-remove-${id}`)).toBeVisible();
});
-test('removing a block is offered as a bin, not as a cross', () => {
+test('removing a widget is offered as a bin, not as a cross', () => {
const { id } = withBlock();
// A cross on a card is the gesture for dismissing the card — closing it,
- // putting it away, getting it off screen. This takes the block off the
+ // putting it away, getting it off screen. This takes the widget off the
// dashboard, and the bin is what says that everywhere else in the app.
expect(
- screen.getByTestId(`block-remove-${id}`).querySelector('.anticon-delete'),
+ screen.getByTestId(`widget-remove-${id}`).querySelector('.anticon-delete'),
).toBeInTheDocument();
});
test('the root carries no header of its own', () => {
const rootId = provider.getRoot().id;
- render(<BuildingBlockView nodeId={rootId} />);
+ render(<WidgetView nodeId={rootId} />);
// The root is the dashboard rather than something on it: a header there
// would label it "Canvas" and offer a delete the provider refuses.
expect(
- screen.queryByTestId(`block-header-${rootId}`),
+ screen.queryByTestId(`widget-header-${rootId}`),
).not.toBeInTheDocument();
expect(
- screen.queryByTestId(`block-remove-${rootId}`),
+ screen.queryByTestId(`widget-remove-${rootId}`),
).not.toBeInTheDocument();
});
-test("a block's name reads as its title, not as a caption on it", () => {
+test("a widget's name reads as its title, not as a caption on it", () => {
const { id } = withBlock();
// Set in the secondary colour at the small size, it read as an annotation
- // hanging above the block rather than as the name of the thing below it —
+ // hanging above the widget rather than as the name of the thing below it —
// which is what it is, and the first thing anyone scanning the canvas uses
- // to tell one block from the next.
- const title = screen.getByTestId(`block-title-${id}`);
+ // to tell one widget from the next.
+ const title = screen.getByTestId(`widget-title-${id}`);
expect(title).toHaveStyle({ color: 'rgba(0, 0, 0, 0.88)' });
// Compared rather than pinned: `fontWeightStrong` is a theme token, and it
@@ -101,28 +101,28 @@ test("a block's name reads as its title, not as a caption on it", () => {
expect(Number(getComputedStyle(title).fontWeight)).toBeGreaterThan(400);
});
-/** The element a node draws itself as — the card, for a block that has one. */
+/** The element a node draws itself as — the card, for a widget that has one. */
const frameOf = (id: string) =>
document.querySelector(`[data-node-id="${id}"]`) as HTMLElement;
-test('a block hides what it is drawn over, name and all', () => {
+test('a widget hides what it is drawn over, name and all', () => {
const { rootId, id } = withBlock();
- // A free canvas lets blocks overlap, and only the leaf's own box was ever
- // opaque — so a block raised to the front still showed whatever sat behind
- // it through the strip carrying its name, and two overlapping blocks
+ // A free canvas lets widgets overlap, and only the leaf's own box was ever
+ // opaque — so a widget raised to the front still showed whatever sat behind
+ // it through the strip carrying its name, and two overlapping widgets
// rendered their names on top of each other.
expect(frameOf(id)).toHaveStyle({ backgroundColor: '#FFFFFF' });
// The root is the canvas everything is arranged on, not a card on it.
- render(<BuildingBlockView nodeId={rootId} />);
+ render(<WidgetView nodeId={rootId} />);
expect(frameOf(rootId)).not.toHaveStyle({ backgroundColor: '#FFFFFF' });
});
-test('a block is one card, with its name inside the frame rather than above it', () => {
+test('a widget is one card, with its name inside the frame rather than above it', () => {
const { id } = withBlock();
// The frame was drawn by the leaf, which begins below the header — so a
- // card's top edge ran between a block's name and its contents, and the name
+ // card's top edge ran between a widget's name and its contents, and the name
// read as a caption floating over a separate box rather than as the head of
// the card it belongs to. Drawn once, around both, it is one card.
const frame = frameOf(id);
@@ -133,34 +133,34 @@ test('a block is one card, with its name inside the frame rather than above it',
// And the band no longer paints a surface of its own over the one it is on:
// two backgrounds meeting at the header's edge is the seam this removes.
- expect(screen.getByTestId(`block-header-${id}`).style.backgroundColor).toBe(
+ expect(screen.getByTestId(`widget-header-${id}`).style.backgroundColor).toBe(
'',
);
});
-test('a leaf block no longer frames itself, so there is one border and not two', () => {
+test('a leaf widget no longer frames itself, so there is one border and not two', () => {
const { id } = withBlock();
- const leaf = screen.getByTestId(`block-content-${id}`)
+ const leaf = screen.getByTestId(`widget-content-${id}`)
.firstElementChild as HTMLElement;
expect(leaf.style.border).toBe('');
expect(leaf.style.borderRadius).toBe('');
expect(leaf.style.backgroundColor).toBe('');
});
-test('the header takes its height out of the block, not out of the canvas', () => {
+test('the header takes its height out of the widget, not out of the canvas', () => {
const { rootId, id } = withBlock();
- // A leaf block resolves `height: 100%` against this box — a chart measures
+ // A leaf widget resolves `height: 100%` against this box — a chart measures
// the result to size its canvas — so the band above it has to come out of
- // the height rather than be added to it, or every block overflows its cell
+ // the height rather than be added to it, or every widget overflows its cell
// by exactly the header.
- expect(screen.getByTestId(`block-content-${id}`).style.height).toMatch(
+ expect(screen.getByTestId(`widget-content-${id}`).style.height).toMatch(
/^calc\(100% - \d+px\)$/,
);
// The root has no header to subtract.
- render(<BuildingBlockView nodeId={rootId} />);
- expect(screen.getByTestId(`block-content-${rootId}`)).toHaveStyle({
+ render(<WidgetView nodeId={rootId} />);
+ expect(screen.getByTestId(`widget-content-${rootId}`)).toHaveStyle({
height: '100%',
});
});
diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/WidgetView.tsx
similarity index 74%
rename from superset-frontend/src/core/dashboard/BuildingBlockView.tsx
rename to superset-frontend/src/core/dashboard/WidgetView.tsx
index 4c71812d3f8..af493f40619 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
+++ b/superset-frontend/src/core/dashboard/WidgetView.tsx
@@ -23,9 +23,9 @@ import { ActionButton, Flex, Typography } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { ErrorBoundary } from 'src/components';
import { provider, useDashboardRevision } from './store';
-import { resolveBuildingBlockView } from './resolveBuildingBlockView';
-import { blockLabel } from './blockLabel';
-import { blockHeaderControl } from './blockHeaderControl';
+import { resolveWidgetView } from './resolveWidgetView';
+import { widgetLabel } from './widgetLabel';
+import { widgetHeaderControl } from './widgetHeaderControl';
import RootGrid from './RootGrid';
function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
@@ -48,18 +48,18 @@ function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
}}
>
<Typography.Text type="secondary">
- {t('Unsupported block type:')} {node.type}
+ {t('Unsupported widget type:')} {node.type}
</Typography.Text>
</Flex>
);
}
/**
- * A block's name, and what can be done to the block.
+ * A widget's name, and what can be done to the widget.
*
* Carries no surface of its own and no rule under it. The card behind this is
* opaque and unbroken, so a second background here would only draw a seam
- * across it a hand's width below the top edge — the block would read as a
+ * across it a hand's width below the top edge — the widget would read as a
* strip and a box rather than as one card with a name on it.
*/
const BlockHeader = styled.div`
@@ -74,7 +74,7 @@ const BlockHeader = styled.div`
/**
* What the remove control (and a type's own extra header control, if it has
- * one — see `blockHeaderControl`) sit inside together, pushed to the end of
+ * one — see `widgetHeaderControl`) sit inside together, pushed to the end of
* the header as one group.
*
* Grouped rather than each carrying its own `margin-left: auto`: the two
@@ -83,7 +83,7 @@ const BlockHeader = styled.div`
* independently-pushed elements would not (each would land flush against
* the header's own right edge, stacking on top of one another instead of
* sitting side by side). Pushed to the end whether or not a name is there
- * to share the row with — an unnamed type (see blockLabel's UNNAMED set)
+ * to share the row with — an unnamed type (see widgetLabel's UNNAMED set)
* leaves nothing on the other side to grow and do this instead.
*/
const HeaderTrailingControls = styled.span`
@@ -97,15 +97,15 @@ const HeaderTrailingControls = styled.span`
`;
/**
- * What a type's own extra header control (see `blockHeaderControl`) is
+ * What a type's own extra header control (see `widgetHeaderControl`) is
* wrapped in, and why — the identical reasoning `RemoveSlot`, below, is
* wrapped for: the control itself is an `ActionButton` (or built from one)
* whose `onClick` carries no event, so the two gestures this sits inside are
* stopped here instead — a press on it must act rather than select the
- * block it is drawn on, and a pointer down on it must not start a grid
+ * widget it is drawn on, and a pointer down on it must not start a grid
* drag.
*
- * `data-block-header-control` is the other half of that second one —
+ * `data-widget-header-control` is the other half of that second one —
* `RootGrid` names it in the grid's own drag-cancel selector, which matches
* it up the ancestors, so carrying it here covers the control inside.
*/
@@ -121,10 +121,10 @@ const HeaderControlSlot = styled.span`
* action carried on a surface that is already something else, and the one the
* dashboard list uses for its own Delete. It takes an `onClick` with no event,
* so the two gestures this sits inside are stopped here instead: a click on
- * the bin must remove rather than select the block it is drawn on, and a
+ * the bin must remove rather than select the widget it is drawn on, and a
* pointer down on it must not start a grid drag.
*
- * `data-block-remove` is the other half of that second one — `RootGrid`
+ * `data-widget-remove` is the other half of that second one — `RootGrid`
* names it in the grid's own drag-cancel selector, which matches it up the
* ancestors, so carrying it here covers the button inside.
*/
@@ -133,46 +133,46 @@ const RemoveSlot = styled.span`
flex: 0 0 auto;
`;
-interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
+interface WidgetViewProps extends HTMLAttributes<HTMLDivElement> {
nodeId: string;
}
/**
* The single entry point for rendering a dashboard node. A node's `type` is
- * resolved against `dashboard.buildingBlocks` views — built-in types
+ * resolved against `dashboard.widgets` views — built-in types
* (markdown/echarts/...) and extension-contributed ones are registered
- * identically (see `registerBuiltInBuildingBlocks`), so nothing here knows
+ * identically (see `registerBuiltInWidgets`), so nothing here knows
* or cares which kind it's rendering. Falls back to a placeholder if the
* node doesn't exist, or nothing is registered for its `type`.
*
- * The root is the one exception: it is not a Building Block (see the
+ * The root is the one exception: it is not a Widget (see the
* composition/layout design doc), so there is nothing to look up for it in
* that registry — its renderer, `RootGrid`, is resolved directly instead.
* `RootGrid` positions/sizes each child by wrapping it in a grid item element
* of its own and passing this component an explicit `style={{width:'100%',
* height:'100%'}}` to fill it — the same convention `flowContent.tsx`'s
- * `FlowItem` already used for a flowed block, which is why this accepts
+ * `FlowItem` already used for a flowed widget, which is why this accepts
* `...rest` (covering that `style` prop, among others) and forwards a `ref`
- * rather than each block doing that itself. That's deliberate: a block,
+ * rather than each widget doing that itself. That's deliberate: a widget,
* built-in or extension-contributed, should only ever need to fill 100% of
* whatever box it's given, not know it's sitting in a grid at all, let alone
- * that the grid is draggable/resizable. Before this existed, every block
+ * that the grid is draggable/resizable. Before this existed, every widget
* (and every third-party extension) had to resolve its own placement, which
* meant reimplementing (and risking drifting from) the same parent-lookup
* logic — see `dashboard-insights`'s own `getParentDirection` for what that
* duplication looked like from outside the host bundle, where
* `DashboardProvider` isn't importable at all.
- * `children` (when present) is a block's own extra content layered on top of
+ * `children` (when present) is a widget's own extra content layered on top of
* it rather than replacing it — see `flowContent.tsx`'s `ResizeGrip` for the
* one built-in use of this.
*
- * Wrapped per-node in an ErrorBoundary: a block's content (e.g. an
+ * Wrapped per-node in an ErrorBoundary: a widget's content (e.g. an
* AI-authored `echartsOptions` that turns out malformed at render/effect
* time) is untrusted input the same way a dataset value is, and one bad
- * block must not unmount the rest of the dashboard along with it.
+ * widget must not unmount the rest of the dashboard along with it.
*/
-const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
- function BuildingBlockView({ nodeId, children, ...rest }, ref) {
+const WidgetView = forwardRef<HTMLDivElement, WidgetViewProps>(
+ function WidgetView({ nodeId, children, ...rest }, ref) {
useDashboardRevision();
const theme = useTheme();
const node = provider.getNode(nodeId);
@@ -185,13 +185,13 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
// ever raises an error.
const chrome = nodeId !== provider.getRoot().id;
const isRoot = !chrome;
- // The root's renderer is not looked up in the building-block registry —
+ // The root's renderer is not looked up in the widget registry —
// see this component's own doc comment — since the root was never
// registered there in the first place.
const resolved = isRoot ? (
<RootGrid nodeId={nodeId} />
) : (
- resolveBuildingBlockView(node.type, nodeId)
+ resolveWidgetView(node.type, nodeId)
);
// The same token `BlockHeader` is drawn at: the content box below is this
// element's height minus the band, so the two have to be one number.
@@ -202,26 +202,26 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
ref={ref}
{...rest}
// Where a node is on screen, for the panels that reach into the
- // canvas from outside it — the Outline scrolls to the block it just
+ // canvas from outside it — the Outline scrolls to the widget it just
// selected by finding it here. Set after the spread so a parent
// renderer cannot displace a node's own identity.
data-node-id={nodeId}
- // Every block is a thing an author selects, so every block is a
+ // Every widget is a thing an author selects, so every widget is a
// control — announced as one, reachable by Tab, and answering the
// keys a control answers. The outline offers the same selection in a
- // tree, but a block you can point at and not reach from the keyboard
- // is still a block half the people using this cannot select.
+ // tree, but a widget you can point at and not reach from the keyboard
+ // is still a widget half the people using this cannot select.
// A real `button` is not available: this element carries its own
// ref and an injected `style` (see this component's own doc
- // comment), and a block's content is interactive in its own right —
+ // comment), and a widget's content is interactive in its own right —
// a chart, a table — which a `button` may not contain.
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={node.type}
- // The propagation stop is what makes a click on a block inside a
- // container select the block rather than the container holding it —
+ // The propagation stop is what makes a click on a widget inside a
+ // container select the widget rather than the container holding it —
// both are nodes and both render through here, so the innermost one
// has to claim the gesture.
onClick={event => {
@@ -229,6 +229,11 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
provider.setSelection(nodeId);
}}
onKeyDown={event => {
+ // Only act on Enter/Space that originated on this wrapper itself —
+ // not on a bubbled keypress from an interactive descendant (a tab, a
+ // header ActionButton). Hijacking those with preventDefault would
+ // select the widget instead of switching the tab / firing the button.
+ if (event.target !== event.currentTarget) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
@@ -237,28 +242,28 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
}}
style={{
...rest.style,
- // A block's contents are positioned against this element.
+ // A widget's contents are positioned against this element.
// A caller that already set its own `position` (nothing does
// today) is kept; `relative` only fills the gap when it did not.
position: rest.style?.position ?? 'relative',
- // Sized the same way every other block already is — everything
+ // Sized the same way every other widget already is — everything
// that is not the root gets its width/height from whatever
// rendered it (`RootGrid`'s grid item wrapper, or `flowContent.tsx`'s
// `FlowItem`), passed in through `style` and captured above via
// `...rest.style`. The root gets no such caller — it is rendered
// directly, with no props — so without this it has no width or
// height at all and shrinks to whatever its own content happens to
- // be — which is exactly one block tall, with nothing below it to
+ // be — which is exactly one widget tall, with nothing below it to
// drop onto and a scrollbar that flickers in and out as that one
- // block resizes against it.
+ // widget resizes against it.
width: isRoot ? '100%' : rest.style?.width,
height: isRoot ? '100%' : rest.style?.height,
- // The card, drawn around the whole of a block rather than around
+ // The card, drawn around the whole of a widget rather than around
// part of it.
//
- // This used to be each leaf block's own — every one of them opened
+ // This used to be each leaf widget's own — every one of them opened
// with the same background, border and radius — and a leaf begins
- // below the header, so the card's top edge ran between a block's
+ // below the header, so the card's top edge ran between a widget's
// name and its contents. The name sat outside the box it names,
// reading as a caption dropped over a separate card. Drawn here it
// encloses both, which is also the only place it can be drawn from:
@@ -266,10 +271,10 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
// not the leaf's.
//
// Opaque for the same reason it is one card: on a free canvas
- // blocks overlap, and anything a block does not paint is a window
+ // widgets overlap, and anything a widget does not paint is a window
// onto whatever is behind it.
//
- // None of this is true of the root. The root is not a block on the
+ // None of this is true of the root. The root is not a widget on the
// dashboard, it *is* the dashboard — the surface everything else is
// arranged on, not a card among them — so it gets none of a card's
// trappings: no fill, no border, no rounded corners of its own.
@@ -278,7 +283,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
? undefined
: `1px solid ${theme.colorBorderSecondary}`,
borderRadius: isRoot ? undefined : theme.borderRadiusLG,
- // Nothing reaches past the corners this rounds — a block's content
+ // Nothing reaches past the corners this rounds — a widget's content
// is square and would otherwise fill them back in. Moot on the
// root, which rounds nothing.
overflow: isRoot ? undefined : 'hidden',
@@ -290,12 +295,12 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
// not a card, and RootGrid already fills it exactly.
padding: isRoot ? undefined : theme.padding,
boxSizing: 'border-box',
- // Drawn over the block rather than around it: an outline takes no
+ // Drawn over the widget rather than around it: an outline takes no
// space, so nothing on screen shifts when a selection moves.
//
// Never on the root: it can still be selected (see `EditorPanel`'s
// own Properties for it), but the root is the canvas itself, not a
- // block sitting on it, and an outline meant to mark one block out
+ // widget sitting on it, and an outline meant to mark one widget out
// from its neighbors instead reads as a frame around the entire
// dashboard when it is the root wearing it.
outline:
@@ -303,14 +308,14 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
outlineOffset: selected && !isRoot ? -2 : undefined,
}}
>
- {/* What this block is, and how to be rid of it.
- The name comes from `blockLabel`, the same call the Outline names
- a row by, so a block is not "Sales by Territory" in one place and
+ {/* What this widget is, and how to be rid of it.
+ The name comes from `widgetLabel`, the same call the Outline names
+ a row by, so a widget is not "Sales by Territory" in one place and
"ECharts" in the other. A chart's name is authored in its ECharts
- option and ChartBlock stops ECharts drawing it, so it appears here
+ option and ChartWidget stops ECharts drawing it, so it appears here
once instead of twice.
- `data-block-remove` is what keeps a press on the button from
+ `data-widget-remove` is what keeps a press on the button from
starting a grid drag; see RootGrid's own drag-cancel selector.
The propagation stops are the same idea for the two gestures it
sits inside: a click here removes rather than selects, and a
@@ -318,72 +323,72 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
The button is nested inside a control, which is not ideal and is
the price of the wrapper itself being selectable — the alternative
- was a block you can delete only from the panel. The keyboard path
- is not this button: the Outline selects any block with proper tree
+ was a widget you can delete only from the panel. The keyboard path
+ is not this button: the Outline selects any widget with proper tree
semantics and Properties carries the same Delete. */}
{chrome && (
- <BlockHeader data-test={`block-header-${nodeId}`}>
- {/* Skipped entirely for a type `blockLabel` leaves unnamed
+ <BlockHeader data-test={`widget-header-${nodeId}`}>
+ {/* Skipped entirely for a type `widgetLabel` leaves unnamed
(markdown, whose rendered body is right below this and needs
no caption repeating it) — an empty `Typography.Text` would
still be a blank strip claiming the header's whole left
side, not nothing. */}
- {blockLabel(node.type, node.props) && (
+ {widgetLabel(node.type, node.props) && (
<Typography.Text
ellipsis
- data-test={`block-title-${nodeId}`}
+ data-test={`widget-title-${nodeId}`}
style={{
flex: '1 1 auto',
// The name of the thing below it, not a note about it. At the
// small size in the secondary colour it read as a caption
- // hanging over the block — and this is the first thing anyone
- // scanning a canvas uses to tell one block from the next, so
+ // hanging over the widget — and this is the first thing anyone
+ // scanning a canvas uses to tell one widget from the next, so
// it is drawn at the weight that job deserves.
fontSize: theme.fontSize,
fontWeight: theme.fontWeightStrong,
color: theme.colorText,
}}
>
- {blockLabel(node.type, node.props)}
+ {widgetLabel(node.type, node.props)}
</Typography.Text>
)}
<HeaderTrailingControls>
{/* A type's own extra header control — e.g. `collapsible`'s
expand/collapse toggle — sits beside Remove rather than
- below the header, so a block with one of these is still
+ below the header, so a widget with one of these is still
just a title and its content, not a title, a second bar,
- and its content. See `blockHeaderControl`. */}
- {blockHeaderControl(node.type, nodeId) && (
+ and its content. See `widgetHeaderControl`. */}
+ {widgetHeaderControl(node.type, nodeId) && (
<HeaderControlSlot
- data-block-header-control
+ data-widget-header-control
onMouseDown={event => event.stopPropagation()}
onPointerDown={event => event.stopPropagation()}
onClick={event => event.stopPropagation()}
>
- {blockHeaderControl(node.type, nodeId)}
+ {widgetHeaderControl(node.type, nodeId)}
</HeaderControlSlot>
)}
<RemoveSlot
- data-block-remove
+ data-widget-remove
onMouseDown={event => event.stopPropagation()}
onPointerDown={event => event.stopPropagation()}
onClick={event => event.stopPropagation()}
>
<ActionButton
- label={t('Remove block')}
- tooltip={t('Remove block')}
+ label={t('Remove widget')}
+ tooltip={t('Remove widget')}
placement="bottom"
- dataTest={`block-remove-${nodeId}`}
- onClick={() => provider.removeBuildingBlock(nodeId)}
+ dataTest={`widget-remove-${nodeId}`}
+ onClick={() => provider.removeWidget(nodeId)}
// A bin rather than a cross. A cross on a card is the gesture
// for dismissing the card — closing it, putting it away — and
- // this does not put the block away, it takes it off the
+ // this does not put the widget away, it takes it off the
// dashboard. The bin is what the rest of the app uses to say
// so, and it is the same act the panel offers as Delete.
//
// Quiet at rest and primary under the pointer, which is
// `ActionButton`'s own behaviour and the same answer the
- // dashboard list gives for its Delete: a bin on every block,
+ // dashboard list gives for its Delete: a bin on every widget,
// all of them lit red, would make a canvas read as a row of
// things about to be deleted.
icon={<Icons.DeleteOutlined iconSize="s" />}
@@ -392,14 +397,14 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
</HeaderTrailingControls>
</BlockHeader>
)}
- {/* The block's own box, which is the whole of this element's minus
+ {/* The widget's own box, which is the whole of this element's minus
the band above it. Subtracted in pixels off a percentage rather
- than left to a flex column, because what a leaf block does with
+ than left to a flex column, because what a leaf widget does with
the box is resolve `height: 100%` against it — a chart measures
the result to size its canvas — and that wants a height there is
no question about. */}
<div
- data-test={`block-content-${nodeId}`}
+ data-test={`widget-content-${nodeId}`}
style={{
width: '100%',
height: chrome ? `calc(100% - ${headerHeight}px)` : '100%',
@@ -415,4 +420,4 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
},
);
-export default BuildingBlockView;
+export default WidgetView;
diff --git a/superset-frontend/src/core/dashboard/chartData.ts b/superset-frontend/src/core/dashboard/chartData.ts
index d205cca1e7f..81ea4328814 100644
--- a/superset-frontend/src/core/dashboard/chartData.ts
+++ b/superset-frontend/src/core/dashboard/chartData.ts
@@ -41,9 +41,13 @@ async function describeFetchError(e: unknown): Promise<string> {
const detail =
body?.message ??
(Array.isArray(body?.errors)
- ? body.errors.map((err: { message?: string }) => err.message).join('; ')
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
: undefined);
- return detail ? `${e.status} ${e.statusText}: ${detail}` : `${e.status} ${e.statusText}`;
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
} catch {
return `${e.status} ${e.statusText}`;
}
@@ -56,9 +60,23 @@ export async function fetchQueryData(
): Promise<QueryDataResult> {
const formData = {
datasource: `${binding.datasetId}__table`,
- metrics: binding.metrics,
- groupby: binding.dimensions ?? [],
- adhoc_filters: binding.filters ?? [],
+ // Drop empty/blank inputs the schema-driven control panel can seed (an
+ // empty array item — a blank dimension string, or an empty `{}` filter
+ // object) before they reach `buildQueryContext`, which throws on a
+ // malformed adhoc filter (e.g. `{}`) rather than ignoring it.
+ metrics: (binding.metrics ?? []).filter(
+ metric => metric != null && metric !== '',
+ ),
+ groupby: (binding.dimensions ?? []).filter(
+ dimension => typeof dimension === 'string' && dimension !== '',
+ ),
+ adhoc_filters: (binding.filters ?? []).filter(
+ filter =>
+ filter != null &&
+ typeof filter === 'object' &&
+ !Array.isArray(filter) &&
+ ('clause' in filter || 'expressionType' in filter),
+ ),
row_limit: binding.rowLimit ?? 1000,
result_format: 'json',
result_type: 'full',
diff --git a/superset-frontend/src/core/dashboard/gridPacking.test.ts b/superset-frontend/src/core/dashboard/gridPacking.test.ts
index c031398e862..1295995982e 100644
--- a/superset-frontend/src/core/dashboard/gridPacking.test.ts
+++ b/superset-frontend/src/core/dashboard/gridPacking.test.ts
@@ -169,7 +169,7 @@ test('buildOccupancy is empty for an empty grid', () => {
test('availableDropSpan caps width at maxColSpan on a wholly empty grid, so a drop there is not forced full-width', () => {
// Regression: before `maxColSpan` existed, an empty grid had nothing
// anywhere to bound a free run, so "open space" always meant "the whole
- // row" — the ghost (and the block it produced) ignored the cursor's own
+ // row" — the ghost (and the widget it produced) ignored the cursor's own
// column entirely. Growth is still left-first, so a cursor this far from
// the left edge exhausts the cap before reaching it.
expect(availableDropSpan({}, 24, 10, 3, 6, 12)).toEqual({
@@ -239,7 +239,7 @@ test('availableDropSpan over an occupied cell returns the full row, uncapped by
};
// maxColSpan(4) is deliberately narrower than the grid: this band means
- // "insert a full-width row here", not "drop a block here", so it stays
+ // "insert a full-width row here", not "drop a widget here", so it stays
// uncapped regardless.
expect(availableDropSpan(packed, 24, 5, 1, 6, 4)).toEqual({
x: 0,
@@ -259,7 +259,7 @@ test('resolveDropPlacement over open space delegates to availableDropSpan', () =
});
});
-test('resolveDropPlacement in the top band of a block inserts a full-width row above it', () => {
+test('resolveDropPlacement in the top band of a widget inserts a full-width row above it', () => {
const packed: Record<string, PackedRect> = {
target: { x: 4, y: 2, w: 12, h: 8 },
};
@@ -270,7 +270,7 @@ test('resolveDropPlacement in the top band of a block inserts a full-width row a
});
});
-test('resolveDropPlacement in the bottom band of a block inserts a full-width row below it', () => {
+test('resolveDropPlacement in the bottom band of a widget inserts a full-width row below it', () => {
const packed: Record<string, PackedRect> = {
target: { x: 4, y: 2, w: 12, h: 8 },
};
@@ -281,12 +281,12 @@ test('resolveDropPlacement in the bottom band of a block inserts a full-width ro
});
});
-test('resolveDropPlacement in the middle band, left of center, splits the block and shrinks it to the right half', () => {
+test('resolveDropPlacement in the middle band, left of center, splits the widget and shrinks it to the right half', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 24, h: 4 },
};
- // fracY = 0.5, in the middle band; exactCol 6 is left of the block's own
+ // fracY = 0.5, in the middle band; exactCol 6 is left of the widget's own
// midpoint (12).
expect(resolveDropPlacement(packed, 24, 6, 2, 6, 12)).toEqual({
rect: { x: 0, y: 0, w: 12, h: 4 },
@@ -294,7 +294,7 @@ test('resolveDropPlacement in the middle band, left of center, splits the block
});
});
-test('resolveDropPlacement in the middle band, right of center, splits the block and shrinks it to the left half', () => {
+test('resolveDropPlacement in the middle band, right of center, splits the widget and shrinks it to the left half', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 24, h: 4 },
};
@@ -311,14 +311,14 @@ test('resolveDropPlacement gives the odd leftover column to whichever half keeps
};
// w=5 -> newW = floor(5/2) = 2, keepW = 3. exactCol 1 is left of the
- // midpoint (2.5), so the new block takes the narrower half.
+ // midpoint (2.5), so the new widget takes the narrower half.
expect(resolveDropPlacement(packed, 24, 1, 1, 6, 12)).toEqual({
rect: { x: 0, y: 0, w: 2, h: 2 },
shrink: { id: 'target', rect: { x: 2, y: 0, w: 3, h: 2 } },
});
});
-test('resolveDropPlacement refuses to split a block narrower than the minimum, falling back to the nearer edge', () => {
+test('resolveDropPlacement refuses to split a widget narrower than the minimum, falling back to the nearer edge', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 3, h: 2 },
};
diff --git a/superset-frontend/src/core/dashboard/gridPacking.ts b/superset-frontend/src/core/dashboard/gridPacking.ts
index ec298034a13..cd33e05234f 100644
--- a/superset-frontend/src/core/dashboard/gridPacking.ts
+++ b/superset-frontend/src/core/dashboard/gridPacking.ts
@@ -37,7 +37,7 @@ export function cellKey(x: number, y: number): string {
/**
* Which node, if any, occupies each cell of `packed` — a lookup `availableDropSpan`
* needs only as a yes/no test, but `resolveDropPlacement` needs to know *which*
- * block a drop point falls inside, to find that block's own edges. Built fresh
+ * widget a drop point falls inside, to find that widget's own edges. Built fresh
* from `packed` rather than threaded through as its own parameter, since every
* caller already has `packed` to build it from.
*/
@@ -137,20 +137,20 @@ export function packChildLayout(
}
/**
- * How big a block being dropped in at `(cursorCol, cursorRow)` has room for
+ * How big a widget being dropped in at `(cursorCol, cursorRow)` has room for
* — up to `maxRowSpan` tall — given `packed`'s existing occupancy: the pure
* geometry half of `RootGrid`'s own live drop preview, split out here so it
* can be exercised without a real drag gesture, the same reason
* `packChildLayout`'s own placement math lives here rather than inside a
* component. The one entry point that actually decides between this ("land
- * in whatever's already free") and a left/right split of an existing block
+ * in whatever's already free") and a left/right split of an existing widget
* is `resolveDropPlacement`, below, which calls this for its own "open
* space" case.
*
* Open space returns exactly as wide a span as that row has free, capped at
* `columns` and at `maxColSpan` — a wholly empty grid included, since
* nothing anywhere is occupied there either, and without the second cap an
- * empty (or otherwise wide-open) grid would offer a full-width block as the
+ * empty (or otherwise wide-open) grid would offer a full-width widget as the
* *only* size a drop could ever produce there, regardless of where the
* cursor actually is. Growth is left-first, same as it was before
* `maxColSpan` existed, with the cap folded into each step rather than
@@ -160,7 +160,7 @@ export function packChildLayout(
* it, within that same width, stay just as free, capped at `maxRowSpan`. A
* gap that turns out to be as tall as it is wide is not pushing anything
* out of the way at all: it was already free on every side. No minimum
- * width of its own beyond that: every block's own minimum width is 1
+ * width of its own beyond that: every widget's own minimum width is 1
* column, so a single free column is already as legitimate a place to drop
* one as a whole free row is — narrower than that and there is no width
* left to report at all. The returned rect's own `x` is the free run's own
@@ -168,11 +168,11 @@ export function packChildLayout(
* free run would otherwise get a preview wider than the space actually to
* its right.
*
- * Directly over another block there is no *beside* to speak of here, only
+ * Directly over another widget there is no *beside* to speak of here, only
* *above* or *below* it (resolved by vertical collision-avoidance the same
- * way repositioning an existing block already is), so that returns the full
+ * way repositioning an existing widget already is), so that returns the full
* row at `maxRowSpan` instead — deliberately not capped by `maxColSpan`:
- * that band means "insert a full-width row here", not "drop a block here".
+ * that band means "insert a full-width row here", not "drop a widget here".
*/
export function availableDropSpan(
packed: Record<string, PackedRect>,
@@ -224,29 +224,29 @@ export function availableDropSpan(
}
/**
- * How far in from each edge of an occupied block's own height a drop still
+ * How far in from each edge of an occupied widget's own height a drop still
* reads as "insert a full-width row above/below it" rather than "split it
* left/right" — the band `resolveDropPlacement` checks before considering a
* split at all, so that inserting a full-width row between two existing
- * blocks stays reachable once splitting exists (without it, *any* drop onto
- * a block would try to split it, and there would be no way left to ask for
+ * widgets stays reachable once splitting exists (without it, *any* drop onto
+ * a widget would try to split it, and there would be no way left to ask for
* a plain row above or below one).
*/
const EDGE_BAND_FRACTION = 0.25;
/**
- * How many columns a block must span before splitting it is worth
+ * How many columns a widget must span before splitting it is worth
* offering. `resolveDropPlacement` halves a target's width with
* `Math.floor(w / 2)`; below this, one of the two resulting halves would be
- * a sliver too thin to be its own block — narrower than this and the drop
+ * a sliver too thin to be its own widget — narrower than this and the drop
* falls back to the same nearer-edge full-row behavior a top/bottom-band
* drop already gets.
*/
const MIN_SPLIT_COLUMNS = 4;
/**
- * What a drop resolves to: the new block's own rect, and — only when it
- * lands as a left/right split of an existing block — that block's own
+ * What a drop resolves to: the new widget's own rect, and — only when it
+ * lands as a left/right split of an existing widget — that widget's own
* shrunk rect. `RootGrid`'s own live preview and its actual drop handler
* both call `resolveDropPlacement` with the same inputs, so what an author
* sees while hovering is provably what they get on release.
@@ -259,21 +259,21 @@ export interface DropPlacement {
/**
* Resolves a drop at the cursor's own fractional position — `exactCol`/
* `exactRow`, not yet floored to a cell, since a left/right split needs the
- * fraction to tell which half of the target block the cursor is actually
+ * fraction to tell which half of the target widget the cursor is actually
* nearer to, not just which cell it's over.
*
* Landing in open space delegates entirely to `availableDropSpan`, above —
* unchanged from before this function existed. Landing on an existing
- * block is otherwise a plain insert (`compactType`-style push, the same
+ * widget is otherwise a plain insert (`compactType`-style push, the same
* "displace, never overlap" rule `resolveExplicitCollisions` already
* enforces) within `EDGE_BAND_FRACTION` of its top or bottom edge, or a
- * split — new block takes whichever half of the *block's own* width
+ * split — new widget takes whichever half of the *widget's own* width
* (`target.x + target.w / 2`, not the cell the cursor happens to be over)
* it's nearer to, spanning the target's exact row range; the target shrinks
* to the other half, keeping whichever side has the odd leftover column —
* everywhere in between.
*
- * A split result is collision-free by construction: the new block only
+ * A split result is collision-free by construction: the new widget only
* ever occupies columns strictly inside the target's own rectangle, which
* `packChildLayout` already guarantees nothing else in `packed` overlaps.
* That's also why `resolveExplicitCollisions` never needs to run again
@@ -360,7 +360,7 @@ function rectsOverlap(
* one row at a time, until it no longer overlaps an earlier one — the same
* "displace, never shrink" rule interactive resize/drag already gets on the
* root's own grid (see `RootGrid`), applied here for the programmatic
- * placement path (`DashboardProvider.addBuildingBlock`/`updateLayout`, which
+ * placement path (`DashboardProvider.addWidget`/`updateLayout`, which
* an extension's AI tools call directly) so both give the same "nothing ends
* up stuck overlapping" guarantee, not just the one driven by a mouse. The
* one place anything *does* get shrunk instead of displaced is a left/right
diff --git a/superset-frontend/src/core/dashboard/index.ts b/superset-frontend/src/core/dashboard/index.ts
index e39f1df75a8..c0dc51ea12e 100644
--- a/superset-frontend/src/core/dashboard/index.ts
+++ b/superset-frontend/src/core/dashboard/index.ts
@@ -33,22 +33,22 @@
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { provider, useDashboardRevision } from './store';
import { fetchQueryData } from './chartData';
-import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
+import { registerBuiltInWidgets } from './registerBuiltInWidgets';
-// Built-in block types (canvas/markdown/echarts) are registered the same
-// way an extension registers its own — see registerBuiltInBuildingBlocks.
+// Built-in widget types (canvas/markdown/echarts) are registered the same
+// way an extension registers its own — see registerBuiltInWidgets.
// Doing this here guarantees it happens before anything imports `dashboard`
// to render a node, regardless of which page or bridge triggers the import.
-registerBuiltInBuildingBlocks();
+registerBuiltInWidgets();
export { useDashboardRevision };
export const dashboard: typeof dashboardApi = {
getRoot: provider.getRoot,
getNode: provider.getNode,
- addBuildingBlock: provider.addBuildingBlock.bind(provider),
- removeBuildingBlock: provider.removeBuildingBlock.bind(provider),
- moveBuildingBlock: provider.moveBuildingBlock.bind(provider),
+ addWidget: provider.addWidget.bind(provider),
+ removeWidget: provider.removeWidget.bind(provider),
+ moveWidget: provider.moveWidget.bind(provider),
updateLayout: provider.updateLayout.bind(provider),
updateProps: provider.updateProps.bind(provider),
onDidLayoutChange: provider.onDidLayoutChange,
diff --git a/superset-frontend/src/core/dashboard/layoutStyle.ts b/superset-frontend/src/core/dashboard/layoutStyle.ts
index b2081327ca0..7608cd06057 100644
--- a/superset-frontend/src/core/dashboard/layoutStyle.ts
+++ b/superset-frontend/src/core/dashboard/layoutStyle.ts
@@ -83,7 +83,7 @@ export function resolveCellGeometry(
* Converts a pointer position — already relative to the grid container's
* own top-left corner, in pixels — into the fractional column/row it falls
* on. Fractional, not floored: `resolveDropPlacement` (`gridPacking.ts`)
- * needs the fraction to tell which half of a target block the pointer is
+ * needs the fraction to tell which half of a target widget the pointer is
* actually nearer to, not just which cell it's over.
*/
export function cellAtPoint(
diff --git a/superset-frontend/src/core/dashboard/placement.ts b/superset-frontend/src/core/dashboard/placement.ts
index f07ab1b85a3..c78d56cfe14 100644
--- a/superset-frontend/src/core/dashboard/placement.ts
+++ b/superset-frontend/src/core/dashboard/placement.ts
@@ -18,13 +18,13 @@
*/
/**
- * @fileoverview Placing a new block, in the one place both ways of asking
+ * @fileoverview Placing a new widget, in the one place both ways of asking
* for it can reach.
*
- * A block arrives on a dashboard two ways — clicked in the palette, or
+ * A widget arrives on a dashboard two ways — clicked in the palette, or
* dragged from it onto a container — and they must produce the same node. Two
- * copies of "what a freshly placed block looks like" is how a block dropped
- * into a section ends up subtly different from the same block clicked into
+ * copies of "what a freshly placed widget looks like" is how a widget dropped
+ * into a section ends up subtly different from the same widget clicked into
* it, and the difference is invisible until someone hits it.
*/
@@ -37,26 +37,26 @@ import { provider } from './store';
*
* A private type rather than `text/plain` so a drop of anything else — a
* file, a selection of text, a drag from another application — is not read
- * as a request to place a block.
+ * as a request to place a widget.
*/
-export const PALETTE_MIME = 'application/x-dashboard-building-block';
+export const PALETTE_MIME = 'application/x-dashboard-widget';
/**
* A type's own starting `rowSpan` on the root grid, in row tracks (a row is
* `rowUnitPx` tall, 32px by default — see `layoutStyle.ts`). Left unset
- * (`gridPacking.ts`'s own fallback of 1 row) a freshly placed block sits
- * shorter than `BuildingBlockView`'s header-plus-padding chrome alone, before
+ * (`gridPacking.ts`'s own fallback of 1 row) a freshly placed widget sits
+ * shorter than `WidgetView`'s header-plus-padding chrome alone, before
* any content of its own — every leaf type would open already clipped or
* scrolling. Tuned per type instead of one shared number because what fills
* that box varies enough that one height leaves half of them cramped and the
* other half wasting space: a metric tile is a few lines centered in a card,
* a table wants room for a header row and several data rows, a fresh `tabs`
* pays for a tab bar and a flow area's own padding before its empty state
- * even starts. An author who wants a block smaller still has the resize
+ * even starts. An author who wants a widget smaller still has the resize
* handle for that — this is only the size nobody has touched it at yet.
*
* Root-grid-only: a `rowSpan` off this table is meaningless (and, worse,
- * silently wrong) for a block placed into anything else — a `tabs` pane, a
+ * silently wrong) for a widget placed into anything else — a `tabs` pane, a
* `collapsible`, a `carousel` slide — since those read `rowSpan` in their
* own unit, a flow area's own pixel (see `FlowContent`'s own comment), not
* a grid row track. `placeBlock`/`placeBlockAt` only reach into this table
@@ -74,13 +74,13 @@ const DEFAULT_ROW_SPAN: Record<string, number> = {
/**
* Row span for a type this module has no specific tuning for — an
- * extension-contributed block, most likely — and also `RootGrid`'s own
- * starting height for a block whose *position* came from a palette drag
+ * extension-contributed widget, most likely — and also `RootGrid`'s own
+ * starting height for a widget whose *position* came from a palette drag
* rather than this module's per-type table (see `placeBlockAt`): a drag
- * already answers where a block lands, live, as the gesture happens, and
+ * already answers where a widget lands, live, as the gesture happens, and
* asking it to also settle on a bespoke height per type at the same time is
* more than one gesture should have to carry. Generous rather than tight:
- * better an unfamiliar block opens a little taller than it needed to than
+ * better an unfamiliar widget opens a little taller than it needed to than
* clipped or scrolling before anyone has seen what it renders.
*/
export const FALLBACK_ROW_SPAN = 6;
@@ -92,15 +92,15 @@ export const FALLBACK_ROW_SPAN = 6;
* grid, where nothing anywhere is occupied and the free run in any row
* spans every column there is — so a drop there would always be full-width,
* regardless of where the cursor actually sits, which reads as the ghost
- * (and the block it produces) ignoring the drag entirely rather than
- * following it. A real gap between two existing blocks narrower than this
+ * (and the widget it produces) ignoring the drag entirely rather than
+ * following it. A real gap between two existing widgets narrower than this
* is untouched by it — `availableDropSpan` still returns exactly that gap's
* own width, never wider.
*/
export const FALLBACK_COL_SPAN = Math.floor(DEFAULT_COLUMNS / 2);
/**
- * Places a new block of `type` at the end of `parentId`'s children and
+ * Places a new widget of `type` at the end of `parentId`'s children and
* selects it, returning its id.
*
* A container arrives with the grid every other container defaults to, so a
@@ -112,7 +112,7 @@ export const FALLBACK_COL_SPAN = Math.floor(DEFAULT_COLUMNS / 2);
* `rowSpan` is only ever set here when `parentId` is the root's own grid —
* everywhere else (a `tabs` pane, a `collapsible`, a `carousel` slide) it's
* left unset entirely, on purpose, so `FlowItem` (see `flowContent.tsx`)
- * reads that as "no height chosen yet" and flexes the block to fill
+ * reads that as "no height chosen yet" and flexes the widget to fill
* whatever room the container actually has, rather than a grid-row number
* misread as a pixel count.
*/
@@ -122,7 +122,7 @@ export function placeBlock(parentId: string, type: string): string {
const rowSpan = onRootGrid
? (DEFAULT_ROW_SPAN[type] ?? FALLBACK_ROW_SPAN)
: undefined;
- const id = provider.addBuildingBlock(parentId, index, {
+ const id = provider.addWidget(parentId, index, {
type,
layout: isContainerType(type)
? { columns: DEFAULT_COLUMNS, gap: 16, colSpan: DEFAULT_COLUMNS, rowSpan }
@@ -133,10 +133,10 @@ export function placeBlock(parentId: string, type: string): string {
}
/**
- * Places a new block of `type` at an explicit grid cell and an explicit
+ * Places a new widget of `type` at an explicit grid cell and an explicit
* spot in `parentId`'s own reading order, rather than appending it
* full-width at the end the way `placeBlock` does — `RootGrid`'s own
- * counterpart for a palette block dropped onto the root's grid, where
+ * counterpart for a palette widget dropped onto the root's grid, where
* *where* (and how wide, next to whatever it landed beside) was the entire
* point of the gesture.
*
@@ -150,9 +150,9 @@ export function placeBlock(parentId: string, type: string): string {
* to get right: `DashboardProvider`'s own collision resolution (see
* `resolveExplicitCollisions`) settles a tie between two explicitly placed
* siblings by pushing down whichever comes *later* in `children` — so a
- * block dropped, say, between two existing rows has to land earlier in that
+ * widget dropped, say, between two existing rows has to land earlier in that
* order than the row it is displacing, or collision resolution reads it
- * backwards and pushes the new block itself down past everything instead of
+ * backwards and pushes the new widget itself down past everything instead of
* making room for it where it was actually dropped. `RootGrid` is what
* already knows every sibling's own current position (it just packed them,
* to draw this render's preview in the first place), so it is the one that
@@ -165,7 +165,7 @@ export function placeBlockAt(
index: number,
position: { col: number; row: number; colSpan: number; rowSpan: number },
): string {
- const id = provider.addBuildingBlock(parentId, index, {
+ const id = provider.addWidget(parentId, index, {
type,
layout: isContainerType(type)
? { columns: DEFAULT_COLUMNS, gap: 16, ...position }
diff --git a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts b/superset-frontend/src/core/dashboard/registerBuiltInWidgets.ts
similarity index 57%
rename from superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
rename to superset-frontend/src/core/dashboard/registerBuiltInWidgets.ts
index 06f6d8a3b85..fd3268cbf69 100644
--- a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
+++ b/superset-frontend/src/core/dashboard/registerBuiltInWidgets.ts
@@ -17,32 +17,33 @@
* under the License.
*/
import { views } from 'src/core/views';
-import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
+import { DASHBOARD_WIDGETS_LOCATION } from './resolveWidgetView';
import { registerContainerType } from './DashboardProvider';
-import MarkdownBlock from './blocks/MarkdownBlock';
-import ChartBlock from './blocks/ChartBlock';
-import AgGridTableBlock from './blocks/AgGridTableBlock';
-import MetricTileBlock from './blocks/MetricTileBlock';
-import TabsBlock, { TAB_TYPE } from './blocks/TabsBlock';
-import CollapsibleBlock from './blocks/CollapsibleBlock';
-import CarouselBlock, { SLIDE_TYPE } from './blocks/CarouselBlock';
+import MarkdownWidget from './widgets/MarkdownWidget';
+import ChartWidget from './widgets/ChartWidget';
+import AgGridTableWidget from './widgets/AgGridTableWidget';
+import MetricTileWidget from './widgets/MetricTileWidget';
+import BalloonsWidget from './widgets/BalloonsWidget';
+import TabsWidget, { TAB_TYPE } from './widgets/TabsWidget';
+import CollapsibleWidget from './widgets/CollapsibleWidget';
+import CarouselWidget, { SLIDE_TYPE } from './widgets/CarouselWidget';
let registered = false;
/**
- * Registers the built-in block types through the exact same `views` call an
+ * Registers the built-in widget types through the exact same `views` call an
* extension uses to contribute one of its own — markdown/echarts/
* ag-grid-table/metric-tile/tabs have no special status in the render path
- * (see `BuildingBlockView`), they're just pre-registered here before
+ * (see `WidgetView`), they're just pre-registered here before
* anything else has a chance to render a dashboard node.
*
* `grid` — the root's own type — is deliberately not among them. The root
- * is not a Building Block (see the composition/layout design doc): nothing
- * ever places one, and `BuildingBlockView` resolves the root's renderer
+ * is not a Widget (see the composition/layout design doc): nothing
+ * ever places one, and `WidgetView` resolves the root's renderer
* directly rather than through this registry (the same reason `tab`, below,
- * isn't registered either — see `TabsBlock`).
+ * isn't registered either — see `TabsWidget`).
*/
-export function registerBuiltInBuildingBlocks(): void {
+export function registerBuiltInWidgets(): void {
if (registered) return;
registered = true;
@@ -52,8 +53,8 @@ export function registerBuiltInBuildingBlocks(): void {
name: 'Markdown',
description: 'Renders Markdown content.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- MarkdownBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ MarkdownWidget,
);
views.registerView(
{
@@ -61,8 +62,8 @@ export function registerBuiltInBuildingBlocks(): void {
name: 'ECharts',
description: 'Renders a chart from an ECharts option object.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- ChartBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ ChartWidget,
);
views.registerView(
{
@@ -70,8 +71,8 @@ export function registerBuiltInBuildingBlocks(): void {
name: 'Table',
description: 'Renders query results as an AG Grid table.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- AgGridTableBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ AgGridTableWidget,
);
views.registerView(
{
@@ -79,46 +80,57 @@ export function registerBuiltInBuildingBlocks(): void {
name: 'Metric Tile',
description: 'Renders a single live metric value as a "big number".',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- MetricTileBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ MetricTileWidget,
+ );
+ views.registerView(
+ {
+ id: 'balloons',
+ name: 'Balloons',
+ description:
+ 'Bouncing colored balls, one per query row (Chart Framework v2 POC). ' +
+ 'Schema-driven controls served from the backend.',
+ },
+ DASHBOARD_WIDGETS_LOCATION,
+ BalloonsWidget,
);
views.registerView(
{
id: 'tabs',
name: 'Tabs',
- description: 'Groups building blocks into switchable tabs.',
+ description: 'Groups widgets into switchable tabs.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- TabsBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ TabsWidget,
);
views.registerView(
{
id: 'collapsible',
name: 'Collapsible',
- description: 'Holds a single building block behind a show/hide toggle.',
+ description: 'Holds a single widget behind a show/hide toggle.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- CollapsibleBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ CollapsibleWidget,
);
views.registerView(
{
id: 'carousel',
name: 'Carousel',
description:
- 'Groups building blocks into slides, navigated vertically one at a time.',
+ 'Groups widgets into slides, navigated vertically one at a time.',
},
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- CarouselBlock,
+ DASHBOARD_WIDGETS_LOCATION,
+ CarouselWidget,
);
// A tab pane / carousel slide holds its own children (in flow — see
- // `TabsBlock`/`CarouselBlock`), but neither is registered as a view:
- // nothing ever resolves one through `resolveBuildingBlockView` — each
+ // `TabsWidget`/`CarouselWidget`), but neither is registered as a view:
+ // nothing ever resolves one through `resolveWidgetView` — each
// renders its pane's/slide's children directly rather than rendering the
// node itself. They only need to be recognized container types so
- // `addBuildingBlock` gives them a `children` array. `collapsible` needs no
+ // `addWidget` gives them a `children` array. `collapsible` needs no
// such private type: its one child is held directly, with no intermediate
- // pane (see `CollapsibleBlock`).
+ // pane (see `CollapsibleWidget`).
registerContainerType('tabs');
registerContainerType(TAB_TYPE);
registerContainerType('collapsible');
diff --git a/superset-frontend/src/core/dashboard/resolveBindings.ts b/superset-frontend/src/core/dashboard/resolveBindings.ts
index d752d1c1d5e..fe90ef994a7 100644
--- a/superset-frontend/src/core/dashboard/resolveBindings.ts
+++ b/superset-frontend/src/core/dashboard/resolveBindings.ts
@@ -90,9 +90,9 @@ function looksLikeUnwrappedBind(
* much later, inside ECharts' own `setOption`, as a generic
* "series.data ... must be an array" console error with no indication that
* the actual cause was an incomplete `$bind` several layers up. Throwing
- * here instead — during `resolveBindings`, called from `ChartBlock`'s
- * render — gets caught by the `ErrorBoundary` already wrapping every block
- * (see `BuildingBlockView`) and reported as this specific block's error,
+ * here instead — during `resolveBindings`, called from `ChartWidget`'s
+ * render — gets caught by the `ErrorBoundary` already wrapping every widget
+ * (see `WidgetView`) and reported as this specific widget's error,
* naming the exact marker that was incomplete.
*/
function resolveBind(bind: BindMarker['$bind'], ctx: BindContext): unknown {
diff --git a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts b/superset-frontend/src/core/dashboard/resolveWidgetView.ts
similarity index 75%
rename from superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
rename to superset-frontend/src/core/dashboard/resolveWidgetView.ts
index a0ed9d62060..bb6cb89b8f6 100644
--- a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
+++ b/superset-frontend/src/core/dashboard/resolveWidgetView.ts
@@ -21,30 +21,30 @@ import { resolveView, views } from 'src/core/views';
/**
* The `views` location a dashboard node's `type` must be registered at to
- * be renderable as a building block — built-in types (markdown/echarts/...,
- * see `registerBuiltInBuildingBlocks`) and extension-contributed ones
+ * be renderable as a widget — built-in types (markdown/echarts/...,
+ * see `registerBuiltInWidgets`) and extension-contributed ones
* register here identically, through the same `views.registerView` call.
* The root's own type (`grid`) is deliberately not among them — it is not
- * a Building Block, and `BuildingBlockView` resolves its renderer directly
+ * a Widget, and `WidgetView` resolves its renderer directly
* rather than through this location.
*/
-export const DASHBOARD_BUILDING_BLOCKS_LOCATION = 'dashboard.buildingBlocks';
+export const DASHBOARD_WIDGETS_LOCATION = 'dashboard.widgets';
/**
* Resolves a node's registered view, scoped to
- * `DASHBOARD_BUILDING_BLOCKS_LOCATION` — `resolveView` alone resolves by id
+ * `DASHBOARD_WIDGETS_LOCATION` — `resolveView` alone resolves by id
* only, ignoring location, so without this check a node whose `type`
* happened to collide with some unrelated view id registered elsewhere in
* the app could render the wrong thing. Returns undefined if no building
- * block is registered for `type`, so the caller can fall back to an
+ * widget is registered for `type`, so the caller can fall back to an
* "unsupported" placeholder.
*/
-export function resolveBuildingBlockView(
+export function resolveWidgetView(
type: string,
nodeId: string,
): ReactElement | undefined {
const isRegistered = views
- .getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
+ .getViews(DASHBOARD_WIDGETS_LOCATION)
?.some(view => view.id === type);
if (!isRegistered) return undefined;
return resolveView(type, { nodeId });
diff --git a/superset-frontend/src/core/dashboard/store.ts b/superset-frontend/src/core/dashboard/store.ts
index fb557104795..a775b7c21f6 100644
--- a/superset-frontend/src/core/dashboard/store.ts
+++ b/superset-frontend/src/core/dashboard/store.ts
@@ -20,10 +20,10 @@
/**
* @fileoverview Leaf module wrapping the `DashboardProvider` singleton.
*
- * Building block components (built-in or extension-contributed) read from
+ * Widget components (built-in or extension-contributed) read from
* `provider` and subscribe via `useDashboardRevision` directly — importing
* from here rather than from `./index` avoids a cycle, since `./index` is
- * what registers the built-in blocks (which import the provider) in the
+ * what registers the built-in widgets (which import the provider) in the
* first place.
*/
diff --git a/superset-frontend/src/core/dashboard/blockHeaderControl.tsx b/superset-frontend/src/core/dashboard/widgetHeaderControl.tsx
similarity index 82%
rename from superset-frontend/src/core/dashboard/blockHeaderControl.tsx
rename to superset-frontend/src/core/dashboard/widgetHeaderControl.tsx
index 7f2a5a83b1a..45914742870 100644
--- a/superset-frontend/src/core/dashboard/blockHeaderControl.tsx
+++ b/superset-frontend/src/core/dashboard/widgetHeaderControl.tsx
@@ -18,27 +18,27 @@
*/
/**
- * @fileoverview A second control in a block's own header, beside the remove
- * button — the header-side counterpart to `blockLabel`. Most block types
+ * @fileoverview A second control in a widget's own header, beside the remove
+ * button — the header-side counterpart to `widgetLabel`. Most widget types
* have nothing to put there and get nothing rendered. `collapsible` needs an
* expand/collapse toggle next to its remove control rather than a second bar
- * of its own further down the card (see `CollapsibleBlock`); `carousel`
+ * of its own further down the card (see `CollapsibleWidget`); `carousel`
* needs a way to add a slide that isn't the dot strip itself, since the dot
* strip is meant to read as a plain position indicator rather than a row of
- * controls (see `CarouselBlock`).
+ * controls (see `CarouselWidget`).
*/
import type { ReactElement } from 'react';
import { t } from '@apache-superset/core/translation';
import { ActionButton } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { provider } from './store';
-import { SLIDE_TYPE, untitledSlideLabel } from './blocks/CarouselBlock';
+import { SLIDE_TYPE, untitledSlideLabel } from './widgets/CarouselWidget';
/**
- * How tall a collapsed block stays — just enough for `BuildingBlockView`'s
+ * How tall a collapsed widget stays — just enough for `WidgetView`'s
* own header, plus a little room around it, rather than the bare minimum
* (`1`) either unit accepts: at exactly the header's own height a collapsed
- * block reads as clipped rather than deliberately shut. Read in
+ * widget reads as clipped rather than deliberately shut. Read in
* `layout.rowSpan`'s own unit, whatever this node's container happens to
* interpret that as (a grid row on the root's own grid, a pixel inside a
* flow area — see the composition/layout design doc).
@@ -48,7 +48,7 @@ const COLLAPSED_ROW_SPAN = 2;
/**
* The height restored on expanding, when nothing narrower was ever
* authored to begin with — the same default a freshly placed container
- * arrives with (see `placeBlock`), so expanding a block nobody has resized
+ * arrives with (see `placeBlock`), so expanding a widget nobody has resized
* yet returns it to exactly the size it was placed at.
*/
const DEFAULT_EXPANDED_ROW_SPAN = 4;
@@ -81,10 +81,10 @@ function CollapsibleToggle({ nodeId }: { nodeId: string }): ReactElement {
return (
<ActionButton
- label={collapsed ? t('Expand block') : t('Collapse block')}
+ label={collapsed ? t('Expand widget') : t('Collapse widget')}
tooltip={collapsed ? t('Expand') : t('Collapse')}
placement="bottom"
- dataTest={`block-collapse-toggle-${nodeId}`}
+ dataTest={`widget-collapse-toggle-${nodeId}`}
onClick={toggle}
icon={
collapsed ? (
@@ -98,17 +98,17 @@ function CollapsibleToggle({ nodeId }: { nodeId: string }): ReactElement {
}
/**
- * Appends a new slide and selects nothing itself — `CarouselBlock` notices
+ * Appends a new slide and selects nothing itself — `CarouselWidget` notices
* the growth on its own next render and switches to it (see its own
* comment). This component can't do that switching directly: it renders as
- * `CarouselBlock`'s sibling in `BuildingBlockView`'s header, not as
+ * `CarouselWidget`'s sibling in `WidgetView`'s header, not as
* anything that could hold or reach the active-slide state living inside
- * `CarouselBlock`.
+ * `CarouselWidget`.
*/
function CarouselAddSlide({ nodeId }: { nodeId: string }): ReactElement {
const addSlide = (): void => {
const index = provider.getNode(nodeId)?.children?.length ?? 0;
- provider.addBuildingBlock(nodeId, index, {
+ provider.addWidget(nodeId, index, {
type: SLIDE_TYPE,
props: { label: untitledSlideLabel(index) },
});
@@ -132,11 +132,11 @@ const HEADER_CONTROLS: Record<string, (nodeId: string) => ReactElement> = {
};
/**
- * A second control for the block of `type` to show in its own header,
+ * A second control for the widget of `type` to show in its own header,
* beside the remove button — or `null` for every type that has nothing to
* put there, which is nearly all of them.
*/
-export function blockHeaderControl(
+export function widgetHeaderControl(
type: string,
nodeId: string,
): ReactElement | null {
diff --git a/superset-frontend/src/core/dashboard/blockLabel.test.ts b/superset-frontend/src/core/dashboard/widgetLabel.test.ts
similarity index 64%
rename from superset-frontend/src/core/dashboard/blockLabel.test.ts
rename to superset-frontend/src/core/dashboard/widgetLabel.test.ts
index 63c78ba8824..c4bb605fe98 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.test.ts
+++ b/superset-frontend/src/core/dashboard/widgetLabel.test.ts
@@ -16,16 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
-import { blockLabel } from './blockLabel';
+import { registerBuiltInWidgets } from './registerBuiltInWidgets';
+import { widgetLabel } from './widgetLabel';
beforeAll(() => {
- registerBuiltInBuildingBlocks();
+ registerBuiltInWidgets();
});
test('a chart is named by the title its author wrote into the option', () => {
expect(
- blockLabel('echarts', {
+ widgetLabel('echarts', {
echartsOptions: { title: { text: 'Sales by Territory' } },
}),
).toBe('Sales by Territory');
@@ -35,53 +35,53 @@ test('a chart carrying several titles is named by the first', () => {
// ECharts takes one title or a list of them; the first is the chart's and
// the rest annotate parts of it.
expect(
- blockLabel('echarts', {
+ widgetLabel('echarts', {
echartsOptions: { title: [{ text: 'Revenue' }, { text: 'Units' }] },
}),
).toBe('Revenue');
});
test('a metric tile is named by the label it displays', () => {
- expect(blockLabel('metric-tile', { label: 'Total Revenue' })).toBe(
+ expect(widgetLabel('metric-tile', { label: 'Total Revenue' })).toBe(
'Total Revenue',
);
});
test('a tab pane is named by its own label', () => {
- expect(blockLabel('tab', { label: 'Overview' })).toBe('Overview');
+ expect(widgetLabel('tab', { label: 'Overview' })).toBe('Overview');
});
-test('a tabs block with no panes yet falls back to its registered name', () => {
- expect(blockLabel('tabs', {})).toBe('Tabs');
+test('a tabs widget with no panes yet falls back to its registered name', () => {
+ expect(widgetLabel('tabs', {})).toBe('Tabs');
});
test('markdown goes unnamed — its rendered body is already its name', () => {
// Unlike a chart's title or a tile's label, markdown's `content` is the
- // whole of what the block renders rather than a field carved out of it,
+ // whole of what the widget renders rather than a field carved out of it,
// and its registered name ("Markdown") says only what it is, not which
// one — worth nothing sitting right above the content itself. Both would
// repeat what a reader is already looking at, so this returns '' rather
// than falling back to either.
expect(
- blockLabel('markdown', { content: '# Acme Corp\n\nGenerated November' }),
+ widgetLabel('markdown', { content: '# Acme Corp\n\nGenerated November' }),
).toBe('');
- expect(blockLabel('markdown', {})).toBe('');
+ expect(widgetLabel('markdown', {})).toBe('');
});
-test('a block with no name of its own is named by what it is', () => {
- // "Table" says what a block is rather than which one it is — worth little,
+test('a widget with no name of its own is named by what it is', () => {
+ // "Table" says what a widget is rather than which one it is — worth little,
// and still better than an empty header.
- expect(blockLabel('ag-grid-table', {})).toBe('Table');
- expect(blockLabel('echarts', undefined)).toBe('ECharts');
+ expect(widgetLabel('ag-grid-table', {})).toBe('Table');
+ expect(widgetLabel('echarts', undefined)).toBe('ECharts');
});
test('a name of nothing but spaces is no name', () => {
expect(
- blockLabel('echarts', { echartsOptions: { title: { text: ' ' } } }),
+ widgetLabel('echarts', { echartsOptions: { title: { text: ' ' } } }),
).toBe('ECharts');
});
test('a type nothing registered still says something', () => {
- // An extension's block whose registration failed, or arrived late.
- expect(blockLabel('acme-widget', undefined)).toBe('acme-widget');
+ // An extension's widget whose registration failed, or arrived late.
+ expect(widgetLabel('acme-widget', undefined)).toBe('acme-widget');
});
diff --git a/superset-frontend/src/core/dashboard/blockLabel.ts b/superset-frontend/src/core/dashboard/widgetLabel.ts
similarity index 74%
rename from superset-frontend/src/core/dashboard/blockLabel.ts
rename to superset-frontend/src/core/dashboard/widgetLabel.ts
index 9faa0727980..d2fc8e160b5 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.ts
+++ b/superset-frontend/src/core/dashboard/widgetLabel.ts
@@ -18,17 +18,17 @@
*/
/**
- * @fileoverview What a block is called, in the one place every panel that
+ * @fileoverview What a widget is called, in the one place every panel that
* names one can reach.
*
- * A block is named in more than one part of the editor — its own header on
- * the canvas, its row in the outline — and those have to agree. A block
+ * A widget is named in more than one part of the editor — its own header on
+ * the canvas, its row in the outline — and those have to agree. A widget
* called "Sales by Territory" in one and "ECharts" in the other reads as two
- * different blocks.
+ * different widgets.
*/
import { views } from 'src/core/views';
-import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
+import { DASHBOARD_WIDGETS_LOCATION } from './resolveWidgetView';
type Props = Record<string, unknown> | undefined;
@@ -46,15 +46,15 @@ const echartsTitle = (props: Props): unknown => {
};
/**
- * Where a block type carries a name of its own, distinct from what it
+ * Where a widget type carries a name of its own, distinct from what it
* renders.
*
* `markdown` is deliberately not here. A chart's title or a tile's label is
- * a field the block reads once and renders once — naming the block by it
- * and having `ChartBlock` skip drawing its own copy (see `ChartBlock`'s own
+ * a field the widget reads once and renders once — naming the widget by it
+ * and having `ChartWidget` skip drawing its own copy (see `ChartWidget`'s own
* comment) is what keeps it appearing exactly once, in the header, rather
* than twice. Markdown's `content` is not that: it is the whole of what the
- * block renders, not a field carved out of it, so echoing it into the
+ * widget renders, not a field carved out of it, so echoing it into the
* header would print the same words a second time right above the ones the
* author actually wrote — most visibly when that content is nothing but a
* heading, where the two would read as identical. Everything else here is
@@ -72,41 +72,41 @@ const NAMED_BY: Record<string, (props: Props) => unknown> = {
* Types that go unnamed rather than falling back to their registered name.
*
* Every other type says something a reader cannot already see just by
- * looking at the block — "Table" for a grid with no title of its own,
- * "ECharts" for a chart nobody has titled yet. A markdown block has no such
+ * looking at the widget — "Table" for a grid with no title of its own,
+ * "ECharts" for a chart nobody has titled yet. A markdown widget has no such
* gap to fill: its entire rendered body sits right below the header, so
* "Markdown" would be one more label repeating what the reader is already
* looking at, rather than standing in for something otherwise missing.
* `carousel` is here for a different reason: it is meant to read as just a
* slide's own content and the dots beside it, not as a slide sitting inside
- * a captioned card — the same idea `CarouselBlock`'s own missing title bar
+ * a captioned card — the same idea `CarouselWidget`'s own missing title bar
* carries further.
*/
const UNNAMED: ReadonlySet<string> = new Set(['markdown', 'carousel']);
/**
- * What to call the block of `type` holding `props`, or `''` for one that
+ * What to call the widget of `type` holding `props`, or `''` for one that
* goes unnamed (see `UNNAMED`) — callers skip the header's name entirely
* for those rather than rendering an empty label.
*
- * A name the block's own content carries wins, because that is the name its
+ * A name the widget's own content carries wins, because that is the name its
* author gave it and the one they will look for. Only when there is none does
- * this fall back to the registered block name — "Table" — which says what a
- * block is rather than which one it is, and is worth nothing at all when
+ * this fall back to the registered widget name — "Table" — which says what a
+ * widget is rather than which one it is, and is worth nothing at all when
* five of them sit in a column.
*
* Returned whole: how much of a long name fits is the caller's business,
* since a row in a panel and a header on a wide chart cut at different
* points.
*/
-export function blockLabel(type: string, props: Props): string {
+export function widgetLabel(type: string, props: Props): string {
if (UNNAMED.has(type)) return '';
const own = NAMED_BY[type]?.(props);
if (typeof own === 'string' && own.trim() !== '') {
return own.trim().replace(/\s+/g, ' ');
}
const registered = views
- .getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
+ .getViews(DASHBOARD_WIDGETS_LOCATION)
?.find(view => view.id === type);
return registered?.name ?? type;
}
diff --git a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx b/superset-frontend/src/core/dashboard/widgets/AgGridTableWidget.tsx
similarity index 86%
rename from superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/AgGridTableWidget.tsx
index 89a489be903..2810cd8878c 100644
--- a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/AgGridTableWidget.tsx
@@ -31,7 +31,7 @@ import { fetchQueryData } from '../chartData';
type DataBindingSpec = dashboardApi.DataBindingSpec;
type DataRow = dashboardApi.DataRow;
-// No module registration needed here, unlike `ChartBlock`'s own
+// No module registration needed here, unlike `ChartWidget`'s own
// `echarts.use([...])` call — `setupAGGridModules()` already runs
// unconditionally at app bootstrap (see `src/views/App.tsx`), well before
// this (or any other) AG Grid consumer ever renders.
@@ -41,9 +41,9 @@ function deriveColumnDefs(columns: string[]): ColDef[] {
}
/**
- * The built-in `ag-grid-table` building block — registered like any other
- * block (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
- * (generic, viz_type-less — see `chartData.ts`) the same way `ChartBlock`
+ * The built-in `ag-grid-table` widget — registered like any other
+ * widget (see `registerBuiltInWidgets`). Fetches its `dataBinding`
+ * (generic, viz_type-less — see `chartData.ts`) the same way `ChartWidget`
* does, then hands the rows straight to AG Grid via the already-themed
* `ThemedAgGridReact` wrapper. Unlike `echarts`, a table's `rowData`/
* `columnDefs` map directly onto query results with no `$bind`-style
@@ -51,7 +51,7 @@ function deriveColumnDefs(columns: string[]): ColDef[] {
* (e.g. for custom headers, formatting, or widths), but when omitted,
* columns are derived one-to-one from the query's own result columns.
*/
-export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
+export default function AgGridTableWidget({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const [rows, setRows] = useState<DataRow[] | null>(null);
const [columns, setColumns] = useState<string[] | null>(null);
@@ -63,7 +63,7 @@ export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
useEffect(() => {
if (!dataBinding) {
- setError('This table block has no dataBinding.');
+ setError('This table widget has no dataBinding.');
setRows(null);
setColumns(null);
return undefined;
@@ -99,12 +99,12 @@ export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
return (
<div
style={{
- // Fills the box `BuildingBlockView`'s placement wrapper gives this
- // block — always a definite pixel box, same as `ChartBlock`.
+ // Fills the box `WidgetView`'s placement wrapper gives this
+ // widget — always a definite pixel box, same as `ChartWidget`.
width: '100%',
height: '100%',
- // Surface, border and corners belong to the card `BuildingBlockView`
- // draws around this block and the name above it, so that the name is
+ // Surface, border and corners belong to the card `WidgetView`
+ // draws around this widget and the name above it, so that the name is
// inside the frame rather than over it.
overflow: 'hidden',
}}
diff --git a/superset-frontend/src/core/dashboard/widgets/BalloonsWidget.tsx b/superset-frontend/src/core/dashboard/widgets/BalloonsWidget.tsx
new file mode 100644
index 00000000000..7de363bffa0
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/widgets/BalloonsWidget.tsx
@@ -0,0 +1,440 @@
+/**
+ * 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.
+ */
+
+/**
+ * The "balloons" widget (Chart Framework v2 POC) — viz only.
+ *
+ * A playful field of balloons: one balloon per query row, colored by the first
+ * grouping dimension's value (the "series") and sized by the metric, rising on
+ * a wavy string and looping from the bottom once they drift out the top.
+ *
+ * Controls live in the dashboard Inspector, driven by a backend JSON Schema
+ * (see `SchemaControlPanel`). This widget only reads its `node.props`
+ * (`dataBinding` + `customize`) and renders from the query rows fetched via the
+ * v1 chart-data path (`fetchQueryData`) — the same generic interface every
+ * other data-backed widget uses.
+ */
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { Flex, Typography } from '@superset-ui/core/components';
+import { keyframes, css, styled, useTheme } from '@apache-superset/core/theme';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+import { provider, useDashboardRevision } from '../store';
+import { fetchQueryData } from '../chartData';
+
+type DataBindingSpec = dashboardApi.DataBindingSpec;
+
+// A categorical palette of distinct, playful colors — one per series. The
+// literal hex is intentional (and must match the backend palette in
+// superset/widgets/widgets.py so a series' default color is stable
+// before the author touches the customize control), so the theme-color rule is
+// disabled per entry, as the TimeTable palette does.
+const PALETTE = [
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#e74c3c',
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#3498db',
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#2ecc71',
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#f1c40f',
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#9b59b6',
+ // eslint-disable-next-line theme-colors/no-literal-colors
+ '#1abc9c',
+];
+// Balloon diameter is derived from the metric value — sqrt(value / max) mapped
+// into this px range — so tiny values never go microscopic and huge ones never
+// go gargantuan, while relative sizes still read. A per-series `sizeScale`
+// multiplies it, and the final diameter is clamped.
+const BASE_MIN_PX = 18;
+const BASE_MAX_PX = 72;
+const MIN_FINAL_PX = 10;
+const MAX_FINAL_PX = 160;
+// One balloon per query row (bounded by the query's row limit); MAX_BALLOONS is
+// only a safety ceiling so an unusually large limit can't spawn a runaway
+// number of animated nodes.
+const MAX_BALLOONS = 500;
+
+// Deterministic, well-distributed pseudo-randoms per balloon, seeded by its
+// stable key. Index-modulo arithmetic repeats every N balloons, so many rise at
+// the same pace and wave in the same phase (a visible synchronized ripple);
+// hashing the key decorrelates neighbours while staying stable across
+// re-renders (unlike Math.random, which would reshuffle every time rows change).
+function hashSeed(text: string): number {
+ let hash = 2166136261;
+ for (let i = 0; i < text.length; i += 1) {
+ hash ^= text.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0; // eslint-disable-line unicorn/prefer-math-trunc
+}
+
+function makeRandom(seed: number): () => number {
+ let state = seed || 1;
+ return () => {
+ // `| 0` / `>>> 0` are deliberate 32-bit (signed/unsigned) coercions the
+ // PRNG relies on — not truncation, so the Math.trunc suggestion is wrong.
+ // eslint-disable-next-line unicorn/prefer-math-trunc
+ state = (state + 0x6d2b79f5) | 0;
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ // eslint-disable-next-line unicorn/prefer-math-trunc
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// The climb: transform-only (GPU-composited, no per-frame layout) so a whole
+// field of balloons stays smooth. `cqh` is 1% of the container's height, so the
+// travel adapts to the widget size without measuring it in JS.
+const rise = keyframes`
+ from { transform: translateY(20cqh); }
+ to { transform: translateY(-120cqh); }
+`;
+
+// The wave: a full there-and-back sway with a gentle tilt, run on its own
+// element so it composes with the vertical rise into an S-shaped ascent.
+const sway = keyframes`
+ 0% { transform: translateX(-16px) rotate(-5deg); }
+ 50% { transform: translateX(16px) rotate(5deg); }
+ 100% { transform: translateX(-16px) rotate(-5deg); }
+`;
+
+// The string trailing the balloon, waving out of phase with the body.
+const waggle = keyframes`
+ 0%,
+ 100% { transform: rotate(-12deg); }
+ 50% { transform: rotate(12deg); }
+`;
+
+const Floater = styled.div`
+ position: absolute;
+ bottom: 0;
+ animation-name: ${rise};
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ /* Hold a balloon still while hovered so its tooltip is catchable. */
+ &:hover {
+ animation-play-state: paused;
+ }
+`;
+
+const Sway = styled.div`
+ animation-name: ${sway};
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+ &:hover {
+ animation-play-state: paused;
+ }
+`;
+
+const Body = styled.div`
+ position: relative;
+ /* Rounder at the top, tapering to a knot at the bottom — a balloon. */
+ border-radius: 48% 48% 47% 47% / 58% 58% 42% 42%;
+ box-shadow:
+ inset -6px -8px 12px rgba(0, 0, 0, 0.22),
+ inset 5px 5px 10px rgba(255, 255, 255, 0.35);
+ &::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ width: 6px;
+ height: 6px;
+ transform: translate(-50%, -45%) rotate(45deg);
+ background: inherit;
+ filter: brightness(0.85);
+ }
+`;
+
+const StringTail = styled.div`
+ ${({ theme }) => css`
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ width: 1.5px;
+ background: ${theme.colorSplit};
+ transform-origin: top center;
+ animation-name: ${waggle};
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+ `}
+`;
+
+// A custom tooltip driven by hover state (native `title` doesn't show reliably
+// on transformed, overlapping, animated elements). Non-interactive so it never
+// steals the hover from the balloon under it.
+const Tooltip = styled.div`
+ ${({ theme }) => css`
+ position: absolute;
+ transform: translate(-50%, calc(-100% - 12px));
+ padding: 3px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1.4;
+ white-space: nowrap;
+ pointer-events: none;
+ background: ${theme.colorBgSpotlight};
+ color: ${theme.colorTextLightSolid};
+ z-index: 5;
+ `}
+`;
+
+interface SeriesStyle {
+ color?: string;
+ sizeScale?: number;
+}
+interface Customization {
+ series?: Record<string, SeriesStyle>;
+}
+type Row = { series: string; label: string; value: number };
+
+/** Map controls -> DataBindingSpec, run the query, normalize to rows. */
+async function loadRows(
+ binding: DataBindingSpec,
+ dimensions: string[],
+ colorDim: string,
+): Promise<Row[]> {
+ const { columns, rows } = await fetchQueryData(binding);
+ const dimensionSet = new Set(dimensions);
+ // The metric column is whichever result column is NOT one of the grouping
+ // dimensions (the query returns [...dimensions, <metric>]). Finding it by
+ // exclusion is what makes multi-dimension bindings size correctly instead of
+ // mistaking a second dimension for the metric.
+ const metricColumn =
+ columns.find(column => !dimensionSet.has(column)) ??
+ columns[columns.length - 1];
+ return rows.map(row => ({
+ // `series` groups/colors the balloons (the color dimension); `label` joins
+ // every grouping dimension's value so the tooltip identifies the balloon in
+ // full (e.g. "Aaron · boy") regardless of which dimension drives color;
+ // `value` sizes each balloon.
+ series: String(row[colorDim] ?? ''),
+ label:
+ dimensions.map(dimension => String(row[dimension] ?? '')).join(' · ') ||
+ String(row[colorDim] ?? ''),
+ value: Number((metricColumn && row[metricColumn]) ?? 0),
+ }));
+}
+
+export default function BalloonsWidget({ nodeId }: { nodeId: string }) {
+ useDashboardRevision();
+ const theme = useTheme();
+
+ const node = provider.getNode(nodeId);
+ const binding = node?.props?.dataBinding as DataBindingSpec | undefined;
+ const customize = (node?.props?.customize as Customization | undefined) ?? {};
+ const dimensions = binding?.dimensions ?? [];
+ // Color by the chosen color dimension, or the last dimension by default; the
+ // first dimension identifies each balloon. (One balloon per query row.)
+ const explicitColor = node?.props?.colorDimension as string | undefined;
+ const colorDim =
+ explicitColor && dimensions.includes(explicitColor)
+ ? explicitColor
+ : dimensions[dimensions.length - 1];
+ const bindingKey = JSON.stringify(binding ?? null);
+
+ const [rows, setRows] = useState<Row[]>([]);
+ const [error, setError] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [tip, setTip] = useState<{
+ label: string;
+ left: number;
+ top: number;
+ } | null>(null);
+
+ const needsConfig =
+ !binding?.datasetId || !binding.metrics?.length || !colorDim;
+
+ useEffect(() => {
+ if (!binding?.datasetId || !binding.metrics?.length || !colorDim) {
+ setRows([]);
+ return undefined;
+ }
+ let cancelled = false;
+ setError(null);
+ loadRows(binding, dimensions, colorDim)
+ .then(result => !cancelled && setRows(result))
+ .catch(e => {
+ if (!cancelled) {
+ setError(e instanceof Error ? e.message : String(e));
+ setRows([]);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ // dataBinding is a fresh object every render — bindingKey is its stable,
+ // value-equality-comparable proxy; colorDim changes the series mapping.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bindingKey, colorDim]);
+
+ const rowsKey = JSON.stringify(rows);
+
+ // One balloon per query row, each with a stable-but-varied flight so the field
+ // looks organic. Negative delays pre-distribute the balloons along the climb,
+ // so the frame is full on first paint rather than filling from the bottom.
+ const balloons = useMemo(() => {
+ const seriesOrder: string[] = [];
+ rows.forEach(row => {
+ if (!seriesOrder.includes(row.series)) seriesOrder.push(row.series);
+ });
+ const shown = rows.slice(0, MAX_BALLOONS);
+ const maxValue = Math.max(0, ...shown.map(row => row.value));
+ const basePxOf = (value: number) => {
+ if (maxValue <= 0) return (BASE_MIN_PX + BASE_MAX_PX) / 2;
+ const ratio = Math.sqrt(Math.max(0, value) / maxValue);
+ return BASE_MIN_PX + ratio * (BASE_MAX_PX - BASE_MIN_PX);
+ };
+ return shown.map((row, index) => {
+ const key = `${row.series}-${index}`;
+ // Each balloon gets its own pace and phase from its key, so the field
+ // rises organically rather than in synchronized bands.
+ const rand = makeRandom(hashSeed(key));
+ const riseSecs = 9 + rand() * 9; // 9–18s ascent
+ const swaySecs = 2.2 + rand() * 2.8; // 2.2–5s wave
+ return {
+ key,
+ series: row.series,
+ label: row.label,
+ value: row.value,
+ basePx: basePxOf(row.value),
+ colorIndex: Math.max(0, seriesOrder.indexOf(row.series)),
+ left: 3 + rand() * 92,
+ rise: riseSecs,
+ // Continuous negative offsets across the full cycle pre-distribute the
+ // balloons along the climb and spread their wave phases, so nothing
+ // starts in lockstep.
+ riseDelay: -rand() * riseSecs,
+ sway: swaySecs,
+ swayDelay: -rand() * swaySecs,
+ };
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rowsKey]);
+
+ if (!node) return null;
+
+ const seriesStyle = (
+ series: string,
+ colorIndex: number,
+ ): { color: string; sizeScale: number } => {
+ const custom = customize.series?.[series] ?? {};
+ return {
+ color: custom.color ?? PALETTE[colorIndex % PALETTE.length],
+ sizeScale: custom.sizeScale ?? 1,
+ };
+ };
+
+ if (error || needsConfig) {
+ return (
+ <Flex
+ align="center"
+ justify="center"
+ style={{ width: '100%', height: '100%', padding: theme.padding }}
+ >
+ <Typography.Text type={error ? 'danger' : 'secondary'}>
+ {error ??
+ t(
+ 'Set a dataset, a metric, and a grouping dimension to release the balloons.',
+ )}
+ </Typography.Text>
+ </Flex>
+ );
+ }
+
+ return (
+ <div
+ ref={containerRef}
+ style={{
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ minHeight: 220,
+ overflow: 'hidden',
+ // Establishes the query container the balloons' `cqh` rise resolves
+ // against, so the travel tracks the widget's height.
+ containerType: 'size',
+ }}
+ >
+ {balloons.map(balloon => {
+ const { color, sizeScale } = seriesStyle(
+ balloon.series,
+ balloon.colorIndex,
+ );
+ const width = Math.max(
+ MIN_FINAL_PX,
+ Math.min(MAX_FINAL_PX, balloon.basePx * sizeScale),
+ );
+ const height = width * 1.2;
+ // `label` already joins every grouping dimension (e.g. "Aaron · boy").
+ const label = `${balloon.label}: ${balloon.value.toLocaleString()}`;
+ return (
+ <Floater
+ key={balloon.key}
+ style={{
+ left: `${balloon.left}%`,
+ animationDuration: `${balloon.rise}s`,
+ animationDelay: `${balloon.riseDelay}s`,
+ }}
+ >
+ <Sway
+ style={{
+ animationDuration: `${balloon.sway}s`,
+ animationDelay: `${balloon.swayDelay}s`,
+ }}
+ >
+ <Body
+ onMouseEnter={event => {
+ const host = containerRef.current;
+ if (!host) return;
+ const rect = host.getBoundingClientRect();
+ setTip({
+ label,
+ left: event.clientX - rect.left,
+ top: event.clientY - rect.top,
+ });
+ }}
+ onMouseLeave={() => setTip(null)}
+ style={{
+ width,
+ height,
+ cursor: 'pointer',
+ background: `radial-gradient(circle at 32% 26%, rgba(255, 255, 255, 0.55), ${color} 62%)`,
+ }}
+ >
+ <StringTail
+ style={{
+ height: height * 0.85,
+ animationDuration: `${balloon.sway}s`,
+ animationDelay: `${balloon.swayDelay}s`,
+ }}
+ />
+ </Body>
+ </Sway>
+ </Floater>
+ );
+ })}
+ {tip && (
+ <Tooltip style={{ left: tip.left, top: tip.top }}>{tip.label}</Tooltip>
+ )}
+ </div>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx b/superset-frontend/src/core/dashboard/widgets/CarouselWidget.tsx
similarity index 87%
rename from superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/CarouselWidget.tsx
index d643b8732ca..827f7b83c02 100644
--- a/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/CarouselWidget.tsx
@@ -27,22 +27,22 @@ import { FlowContent } from './flowContent';
/**
* A slide's own child type — the vertical-navigation counterpart to
- * `TabsBlock`'s `TAB_TYPE`, and not registered as a building block for the
+ * `TabsWidget`'s `TAB_TYPE`, and not registered as a widget for the
* identical reason: nothing ever resolves one through
- * `resolveBuildingBlockView`, since `CarouselBlock` renders a slide's
+ * `resolveWidgetView`, since `CarouselWidget` renders a slide's
* children directly. It only needs to be a recognized container type so
- * `addBuildingBlock` gives it a `children` array (see `registerContainerType`
+ * `addWidget` gives it a `children` array (see `registerContainerType`
* in `DashboardProvider`).
*/
export const SLIDE_TYPE = 'slide';
/**
* The negative margin is what keeps the nav column full-height — see
- * `TabsBlock`'s own `Root`, which this mirrors for the identical reason: the
- * card's own padding (`BuildingBlockView`) is right for a single thing
+ * `TabsWidget`'s own `Root`, which this mirrors for the identical reason: the
+ * card's own padding (`WidgetView`) is right for a single thing
* filling the card, but wrong for chrome that has to reach the card's own
* edges to read as one. The top is left alone: `carousel` has no *title* in
- * its header (see `blockLabel`'s `UNNAMED` set), but the header itself —
+ * its header (see `widgetLabel`'s `UNNAMED` set), but the header itself —
* carrying at least the remove control — is still there for every non-root
* node, so this box starts below it rather than at the card's true top edge
* regardless.
@@ -59,11 +59,11 @@ const Root = styled.div`
/**
* The dot strip itself — shown only once there is something to navigate
- * between (see `CarouselBlock`'s own render). A dot rather than a labelled
+ * between (see `CarouselWidget`'s own render). A dot rather than a labelled
* button: this is the one built-in container whose own switching control is
* meant to read as a lightweight indicator of position among slides, the
* way a carousel's dots do elsewhere, rather than as a row of named
- * destinations the way `TabsBlock`'s tab bar is.
+ * destinations the way `TabsWidget`'s tab bar is.
*/
const NavColumn = styled.div`
${({ theme }) => css`
@@ -123,11 +123,11 @@ export const untitledSlideLabel = (index: number): string =>
t('Slide %s', index + 1);
/**
- * The built-in `carousel` building block — a container whose own children
+ * The built-in `carousel` widget — a container whose own children
* (each a `slide`, itself a container) are switchable one at a time through
- * a vertical strip of dots, rather than the horizontal tab bar `TabsBlock`
- * uses for the same idea. Registered like any other block (see
- * `registerBuiltInBuildingBlocks`), and like `tabs`, it has no grid of its
+ * a vertical strip of dots, rather than the horizontal tab bar `TabsWidget`
+ * uses for the same idea. Registered like any other widget (see
+ * `registerBuiltInWidgets`), and like `tabs`, it has no grid of its
* own: which slide is showing is this component's own concern, not a
* `layout` fact the document carries (composition/layout design doc).
*
@@ -138,11 +138,11 @@ export const untitledSlideLabel = (index: number): string =>
* slide actually exists.
*
* Which slide is *active* is intentionally not persisted, for the identical
- * reason `TabsBlock`'s active pane is not: it is a fact about who is looking
+ * reason `TabsWidget`'s active pane is not: it is a fact about who is looking
* at the dashboard right now, not about the dashboard itself. It resets to
* the first slide whenever the previously active one no longer exists.
*/
-export default function CarouselBlock({
+export default function CarouselWidget({
nodeId,
}: {
nodeId: string;
@@ -160,14 +160,14 @@ export default function CarouselBlock({
setActiveSlideId(slides[0]);
}
- // A slide added since the last render — whether from `blockHeaderControl`'s
+ // A slide added since the last render — whether from `widgetHeaderControl`'s
// "+" (see its own comment) or a palette drop into an empty carousel above
// — is one nobody has seen yet, so it becomes the one shown rather than
// landing silently behind whichever slide was already active. Both of
// those additions always append, so the newest slide is always the last
// one; a ref rather than a prop is what lets this component notice the
// growth at all, since the button that causes it renders as this one's
- // sibling in `BuildingBlockView`'s header, not as anything that could pass
+ // sibling in `WidgetView`'s header, not as anything that could pass
// it a callback.
const previousSlideCount = useRef(slides.length);
if (slides.length > previousSlideCount.current) {
@@ -225,7 +225,7 @@ export default function CarouselBlock({
if (type !== '') {
event.preventDefault();
event.stopPropagation();
- const slideId = provider.addBuildingBlock(nodeId, 0, {
+ const slideId = provider.addWidget(nodeId, 0, {
type: SLIDE_TYPE,
props: { label: untitledSlideLabel(0) },
});
diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx b/superset-frontend/src/core/dashboard/widgets/ChartWidget.test.tsx
similarity index 92%
rename from superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx
rename to superset-frontend/src/core/dashboard/widgets/ChartWidget.test.tsx
index 9d150946fe6..649759f88f8 100644
--- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/ChartWidget.test.tsx
@@ -18,7 +18,7 @@
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import DashboardProvider from '../DashboardProvider';
-import ChartBlock from './ChartBlock';
+import ChartWidget from './ChartWidget';
const mockSetOption = jest.fn();
@@ -67,7 +67,7 @@ beforeEach(() => {
});
test('a chart does not draw the name its header already carries', async () => {
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'echarts',
props: {
dataBinding: { datasource: 1, columns: ['x'], metrics: [] },
@@ -77,11 +77,11 @@ test('a chart does not draw the name its header already carries', async () => {
},
},
});
- render(<ChartBlock nodeId={id} />);
+ render(<ChartWidget nodeId={id} />);
await waitFor(() => expect(mockSetOption).toHaveBeenCalled());
- // `blockLabel` reads the title out of this same option to name the block,
+ // `widgetLabel` reads the title out of this same option to name the widget,
// so leaving it here would print the chart's name twice, at two sizes, in
// two places. The rest of the option has to survive untouched.
const [option] = mockSetOption.mock.calls[0];
diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx b/superset-frontend/src/core/dashboard/widgets/ChartWidget.tsx
similarity index 91%
rename from superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/ChartWidget.tsx
index 48edfff97c0..d9422a13223 100644
--- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/ChartWidget.tsx
@@ -181,14 +181,14 @@ function EchartsCanvas({
}
/**
- * The built-in `echarts` building block — registered like any other block
- * (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
+ * The built-in `echarts` widget — registered like any other widget
+ * (see `registerBuiltInWidgets`). Fetches its `dataBinding`
* (generic, viz_type-less — see `chartData.ts`), resolves any `$bind`
* markers in its `echartsOptions` against the results, and draws the
* result. No `SuperChart`/`ChartPlugin`/`buildQuery`/`transformProps`
* involved — the AI authors close to a real ECharts `option` directly.
*/
-export default function ChartBlock({ nodeId }: { nodeId: string }) {
+export default function ChartWidget({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const theme = useTheme();
const [containerRef, size] = useElementSize();
@@ -201,7 +201,7 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) {
useEffect(() => {
if (!dataBinding) {
- setError('This chart block has no dataBinding.');
+ setError('This chart widget has no dataBinding.');
setRows(null);
return undefined;
}
@@ -229,10 +229,10 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) {
(node?.props?.echartsOptions as Record<string, unknown>) ?? {},
{ rows, theme },
);
- // The chart's name is drawn by the block's header, which reads it from
- // this same option (see `blockLabel`). Leaving it here too would print it
+ // The chart's name is drawn by the widget's header, which reads it from
+ // this same option (see `widgetLabel`). Leaving it here too would print it
// twice, at two sizes, in two places — and the header's copy is the one
- // that sits where every other block's name sits.
+ // that sits where every other widget's name sits.
const withoutTitle = { ...resolved };
delete withoutTitle.title;
return withoutTitle;
@@ -244,14 +244,14 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) {
<div
ref={containerRef}
style={{
- // Fills the box `BuildingBlockView`'s placement wrapper gives this
- // block — that wrapper is always a definite pixel box (its column
+ // Fills the box `WidgetView`'s placement wrapper gives this
+ // widget — that wrapper is always a definite pixel box (its column
// share of the container's width, its `rowSpan × rowUnit` height),
// so this is never zero or ambiguous.
width: '100%',
height: '100%',
- // Surface, border and corners belong to the card `BuildingBlockView`
- // draws around this block and the name above it, so that the name is
+ // Surface, border and corners belong to the card `WidgetView`
+ // draws around this widget and the name above it, so that the name is
// inside the frame rather than over it.
overflow: 'hidden',
}}
diff --git a/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx b/superset-frontend/src/core/dashboard/widgets/CollapsibleWidget.tsx
similarity index 80%
rename from superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/CollapsibleWidget.tsx
index 52bc83d3186..aa6ae7d0e41 100644
--- a/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/CollapsibleWidget.tsx
@@ -25,7 +25,7 @@ import { FlowContent } from './flowContent';
/**
* The negative margin is what lets `FlowContent`'s own inset (see its own
* comment) reach the card's true edges instead of sitting inside it twice —
- * see `TabsBlock`'s identical `Root`, which this mirrors for the identical
+ * see `TabsWidget`'s identical `Root`, which this mirrors for the identical
* reason. The top is left alone, since this box already starts below the
* card's header rather than at its true top edge.
*/
@@ -40,28 +40,28 @@ const Root = styled.div`
`;
/**
- * The built-in `collapsible` building block — a container that holds a
+ * The built-in `collapsible` widget — a container that holds a
* single child, shown or hidden behind one toggle. Registered like any
- * other block (see `registerBuiltInBuildingBlocks`), and like `tabs`, it has
+ * other widget (see `registerBuiltInWidgets`), and like `tabs`, it has
* no grid of its own.
*
- * The toggle itself is not drawn here: `BuildingBlockView`'s own header
- * already carries this block's name and its remove control for every block
+ * The toggle itself is not drawn here: `WidgetView`'s own header
+ * already carries this widget's name and its remove control for every widget
* type, and a second bar in the content below repeating the same idea would
- * make this the only block type with two header-shaped rows stacked on top
- * of each other. `blockHeaderControl` puts the toggle in that same header,
- * beside the remove control, so a collapsible block is — per its own name —
+ * make this the only widget type with two header-shaped rows stacked on top
+ * of each other. `widgetHeaderControl` puts the toggle in that same header,
+ * beside the remove control, so a collapsible widget is — per its own name —
* a title and its content, nothing else. This component's whole job is
* therefore just the content half: nothing at all while collapsed (the
* header above still reads fine on its own), the flowed child once
* expanded.
*
- * `props.collapsed` is what `blockHeaderControl`'s toggle flips, and it also
+ * `props.collapsed` is what `widgetHeaderControl`'s toggle flips, and it also
* resizes this node's own `layout.rowSpan` down to a header-only height
* while collapsed (see its own comment) — a fact about the dashboard's own
* state an author sets deliberately, not a transient fact about who is
* looking at it right now, so it is persisted rather than kept the way
- * `TabsBlock`'s active pane is.
+ * `TabsWidget`'s active pane is.
*
* There is no intermediate pane node the way `tabs` has one per tab — one
* child is already the simplest container `FlowContent` can hold, so
@@ -69,7 +69,7 @@ const Root = styled.div`
* the drop target the moment that one child exists, which is what makes
* "single child" an actual constraint rather than a suggestion.
*/
-export default function CollapsibleBlock({
+export default function CollapsibleWidget({
nodeId,
}: {
nodeId: string;
diff --git a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx b/superset-frontend/src/core/dashboard/widgets/MarkdownWidget.tsx
similarity index 83%
rename from superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/MarkdownWidget.tsx
index c2f2ad1ac38..7bce053e011 100644
--- a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/MarkdownWidget.tsx
@@ -20,12 +20,12 @@ import { SafeMarkdown } from '@superset-ui/core/components';
import { provider, useDashboardRevision } from '../store';
/**
- * The built-in `markdown` building block — registered like any other block
- * (see `registerBuiltInBuildingBlocks`). Fills the box `BuildingBlockView`'s
+ * The built-in `markdown` widget — registered like any other widget
+ * (see `registerBuiltInWidgets`). Fills the box `WidgetView`'s
* placement wrapper gives it (`width`/`height: 100%`) rather than resolving
* its own grid placement.
*/
-export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
+export default function MarkdownWidget({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const node = provider.getNode(nodeId);
if (!node) return null;
@@ -36,7 +36,7 @@ export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
width: '100%',
height: '100%',
// Surface, border, corners and inset all belong to the card
- // `BuildingBlockView` draws around this block and the name above
+ // `WidgetView` draws around this widget and the name above
// it, so that the name is inside the frame rather than over it.
overflow: 'auto',
}}
diff --git a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx b/superset-frontend/src/core/dashboard/widgets/MetricTileWidget.tsx
similarity index 91%
rename from superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/MetricTileWidget.tsx
index 5d11e0677a6..946a59ba7a1 100644
--- a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/MetricTileWidget.tsx
@@ -83,20 +83,20 @@ function DeltaIndicator({ delta, theme }: { delta: DeltaSpec; theme: Theme }) {
}
/**
- * The built-in `metric-tile` building block ("big number") — registered
- * like any other block (see `registerBuiltInBuildingBlocks`). Fetches its
- * `dataBinding` the same generic way `ChartBlock`/`AgGridTableBlock` do, and
+ * The built-in `metric-tile` widget ("big number") — registered
+ * like any other widget (see `registerBuiltInWidgets`). Fetches its
+ * `dataBinding` the same generic way `ChartWidget`/`AgGridTableWidget` do, and
* renders the first result row's value directly as text — no ECharts
- * gauge/`graphic` text workaround (what an AI reached for before this block
+ * gauge/`graphic` text workaround (what an AI reached for before this widget
* existed), and no `$bind` splicing, since there's nothing here to splice
- * into: the whole point of this block is a single live number.
+ * into: the whole point of this widget is a single live number.
*
* `dataBinding` is expected to resolve one column (one metric, no
* `dimensions`) — the value shown is always the *first* row's value for
* that column; a tile shows one number, so grouping isn't meaningful here
* the way it is for a chart or table.
*/
-export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
+export default function MetricTileWidget({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const theme = useTheme();
const [value, setValue] = useState<unknown>(undefined);
@@ -149,12 +149,12 @@ export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
vertical
justify="center"
style={{
- // Fills the box `BuildingBlockView`'s placement wrapper gives this
- // block — always a definite pixel box, same as `ChartBlock`.
+ // Fills the box `WidgetView`'s placement wrapper gives this
+ // widget — always a definite pixel box, same as `ChartWidget`.
width: '100%',
height: '100%',
// Surface, border, corners and inset all belong to the card
- // `BuildingBlockView` draws around this block and the name above
+ // `WidgetView` draws around this widget and the name above
// it, so that the name is inside the frame rather than over it.
overflow: 'hidden',
}}
diff --git a/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx b/superset-frontend/src/core/dashboard/widgets/TabsWidget.test.tsx
similarity index 76%
rename from superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx
rename to superset-frontend/src/core/dashboard/widgets/TabsWidget.test.tsx
index bf25c8b11f1..9923e8d916e 100644
--- a/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/TabsWidget.test.tsx
@@ -18,13 +18,13 @@
*/
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from '../DashboardProvider';
-import { registerBuiltInBuildingBlocks } from '../registerBuiltInBuildingBlocks';
-import TabsBlock from './TabsBlock';
+import { registerBuiltInWidgets } from '../registerBuiltInWidgets';
+import TabsWidget from './TabsWidget';
const provider = DashboardProvider.getInstance();
beforeAll(() => {
- registerBuiltInBuildingBlocks();
+ registerBuiltInWidgets();
});
beforeEach(() => {
@@ -34,16 +34,16 @@ beforeEach(() => {
/** Creates a bare `tabs` node under the root — rendering is each test's own call, made once its setup (if any) is done. */
const createTabs = (): string => {
const rootId = provider.getRoot().id;
- return provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
+ return provider.addWidget(rootId, 0, { type: 'tabs' });
};
-test('a freshly placed tabs block already has one tab, selected', () => {
+test('a freshly placed tabs widget already has one tab, selected', () => {
const tabsId = createTabs();
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
- // A tabs block with nothing to switch between is not a useful starting
+ // A tabs widget with nothing to switch between is not a useful starting
// point, so this fills it in rather than leaving it for the "+" — see
- // TabsBlock's own layout effect.
+ // TabsWidget's own layout effect.
const tab = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('Nothing in this tab yet')).toBeVisible();
@@ -55,7 +55,7 @@ test('a freshly placed tabs block already has one tab, selected', () => {
test('adding a tab is named after its own position, not the count at click time', () => {
const tabsId = createTabs();
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
// Tab 1 already exists (see the test above) — each click adds the next.
fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
@@ -68,11 +68,11 @@ test('adding a tab is named after its own position, not the count at click time'
test('the only tab offers no way to remove itself', () => {
const tabsId = createTabs();
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
const paneId = provider.getNode(tabsId)?.children?.[0] as string;
- // Removing it would leave a blank tabs block the layout effect would
+ // Removing it would leave a blank tabs widget the layout effect would
// immediately refill anyway, which reads as the control having silently
// done nothing.
expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
@@ -80,7 +80,7 @@ test('the only tab offers no way to remove itself', () => {
test('a second tab can be removed, falling back to the remaining one', () => {
const tabsId = createTabs();
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
const firstPaneId = provider.getNode(tabsId)?.children?.[0] as string;
fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
@@ -98,17 +98,15 @@ test('a second tab can be removed, falling back to the remaining one', () => {
);
});
-test('dropping a palette block onto the active pane places it there', () => {
+test('dropping a palette widget onto the active pane places it there', () => {
const tabsId = createTabs();
- const paneId = provider.addBuildingBlock(tabsId, 0, {
+ const paneId = provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
- const data = new Map([
- ['application/x-dashboard-building-block', 'markdown'],
- ]);
+ const data = new Map([['application/x-dashboard-widget', 'markdown']]);
fireEvent.drop(screen.getByTestId(`tabs-panes-${tabsId}`), {
dataTransfer: {
types: [...data.keys()],
@@ -125,23 +123,23 @@ test('dropping a palette block onto the active pane places it there', () => {
test('clicking a tab shows its own content and hides the other panes', async () => {
const tabsId = createTabs();
- const firstPane = provider.addBuildingBlock(tabsId, 0, {
+ const firstPane = provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
- const secondPane = provider.addBuildingBlock(tabsId, 1, {
+ const secondPane = provider.addWidget(tabsId, 1, {
type: 'tab',
props: { label: 'Detail' },
});
- provider.addBuildingBlock(firstPane, 0, {
+ provider.addWidget(firstPane, 0, {
type: 'markdown',
props: { content: 'Overview content' },
});
- provider.addBuildingBlock(secondPane, 0, {
+ provider.addWidget(secondPane, 0, {
type: 'markdown',
props: { content: 'Detail content' },
});
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
// The first pane is active by default. `findByText` rather than
// `getByText`: `SafeMarkdown` lazy-loads `react-markdown` itself and
@@ -156,21 +154,21 @@ test('clicking a tab shows its own content and hides the other panes', async ()
expect(await screen.findByText('Detail content')).toBeVisible();
});
-test('a flowed block with no height of its own flexes to fill the area, and fixes to a number once grown from the keyboard', () => {
+test('a flowed widget with no height of its own flexes to fill the area, and fixes to a number once grown from the keyboard', () => {
const tabsId = createTabs();
- const pane = provider.addBuildingBlock(tabsId, 0, {
+ const pane = provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
- const chartId = provider.addBuildingBlock(pane, 0, {
+ const chartId = provider.addWidget(pane, 0, {
type: 'markdown',
props: { content: 'Chart stand-in' },
});
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
const handle = screen.getByTestId(`flow-resize-${chartId}`);
// No `rowSpan` of its own yet, so there is no number to report — the
- // block is flexing to fill the area rather than sitting at a fixed size.
+ // widget is flexing to fill the area rather than sitting at a fixed size.
expect(handle).not.toHaveAttribute('aria-valuenow');
handle.focus();
@@ -183,18 +181,18 @@ test('a flowed block with no height of its own flexes to fill the area, and fixe
expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(376);
});
-test('a flowed block cannot be shrunk past the minimum height', () => {
+test('a flowed widget cannot be shrunk past the minimum height', () => {
const tabsId = createTabs();
- const pane = provider.addBuildingBlock(tabsId, 0, {
+ const pane = provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
- const chartId = provider.addBuildingBlock(pane, 0, {
+ const chartId = provider.addWidget(pane, 0, {
type: 'markdown',
layout: { rowSpan: 124 },
props: { content: 'Chart stand-in' },
});
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
const handle = screen.getByTestId(`flow-resize-${chartId}`);
handle.focus();
@@ -206,18 +204,18 @@ test('a flowed block cannot be shrunk past the minimum height', () => {
test('removing the active pane falls back to the first remaining tab', () => {
const tabsId = createTabs();
- provider.addBuildingBlock(tabsId, 0, {
+ provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
- const detailPane = provider.addBuildingBlock(tabsId, 1, {
+ const detailPane = provider.addWidget(tabsId, 1, {
type: 'tab',
props: { label: 'Detail' },
});
- render(<TabsBlock nodeId={tabsId} />);
+ render(<TabsWidget nodeId={tabsId} />);
fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
- act(() => provider.removeBuildingBlock(detailPane));
+ act(() => provider.removeWidget(detailPane));
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
diff --git a/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx b/superset-frontend/src/core/dashboard/widgets/TabsWidget.tsx
similarity index 88%
rename from superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
rename to superset-frontend/src/core/dashboard/widgets/TabsWidget.tsx
index fec50add9e7..b024da48e7c 100644
--- a/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/TabsWidget.tsx
@@ -26,12 +26,12 @@ import { provider, useDashboardRevision } from '../store';
import { FlowContent } from './flowContent';
/**
- * A pane's own child type — not registered as a building block in its own
- * right (see `registerBuiltInBuildingBlocks`), since nothing ever resolves
- * one through `resolveBuildingBlockView`: this component renders a pane's
+ * A pane's own child type — not registered as a widget in its own
+ * right (see `registerBuiltInWidgets`), since nothing ever resolves
+ * one through `resolveWidgetView`: this component renders a pane's
* `children` directly rather than rendering the pane node itself through
- * `BuildingBlockView`. It only needs to be a *container* type (so
- * `addBuildingBlock` gives it a `children` array) — see
+ * `WidgetView`. It only needs to be a *container* type (so
+ * `addWidget` gives it a `children` array) — see
* `registerContainerType` in `DashboardProvider`.
*/
export const TAB_TYPE = 'tab';
@@ -39,7 +39,7 @@ export const TAB_TYPE = 'tab';
/**
* The negative margin is what keeps the tab bar full-width.
*
- * `BuildingBlockView` now insets every block's content by the card's own
+ * `WidgetView` now insets every widget's content by the card's own
* padding (see its own comment) — right for a chart or a table, which is a
* single thing filling the card, but wrong for a strip of tabs, which reads
* as cut short the moment it does not reach the card's edges the way a
@@ -112,7 +112,7 @@ const TabButton = styled.button<{ $active: boolean }>`
* screen reader. The remove control is `ActionButton`, itself a `<button>`,
* and a button inside a button is invalid HTML that nothing downstream can
* reliably navigate into. Wrapped here as two controls sharing a row
- * instead, the same shape `BuildingBlockView`'s own header takes for a name
+ * instead, the same shape `WidgetView`'s own header takes for a name
* and its own remove control.
*/
const TabItem = styled.span`
@@ -125,9 +125,9 @@ const TabItem = styled.span`
const untitledLabel = (index: number): string => t('Tab %s', index + 1);
/**
- * The built-in `tabs` building block — a container whose own children (each
+ * The built-in `tabs` widget — a container whose own children (each
* a `tab` pane, itself a container) are switchable rather than all shown at
- * once. Registered like any other block (see `registerBuiltInBuildingBlocks`),
+ * once. Registered like any other widget (see `registerBuiltInWidgets`),
* it holds children of its own like the root's own grid does — but unlike
* the root, it has no grid: which pane is showing is this component's own
* concern, not a `layout` fact the document carries. Per the
@@ -142,7 +142,7 @@ const untitledLabel = (index: number): string => t('Tab %s', index + 1);
* whenever the previously active one no longer exists — most commonly right
* after that pane is removed, or on a first render with no pane yet.
*/
-export default function TabsBlock({
+export default function TabsWidget({
nodeId,
}: {
nodeId: string;
@@ -159,20 +159,20 @@ export default function TabsBlock({
}
const addTab = (): void => {
- const id = provider.addBuildingBlock(nodeId, panes.length, {
+ const id = provider.addWidget(nodeId, panes.length, {
type: TAB_TYPE,
props: { label: untitledLabel(panes.length) },
});
setActiveTabId(id);
};
- // A tabs block with nothing in it yet has nothing to switch between —
+ // A tabs widget with nothing in it yet has nothing to switch between —
// the first thing anyone would do with one is press + once anyway, so
// this does it for them. An effect rather than done inline during render:
// render must not mutate the document itself, only read it. Laid out
// rather than a plain effect so the fill-in happens before the browser
// ever paints the empty state, which would otherwise flash for one frame
- // on every block placed from the palette.
+ // on every widget placed from the palette.
useLayoutEffect(() => {
if (node && panes.length === 0) {
addTab();
@@ -204,7 +204,7 @@ export default function TabsBlock({
{label}
</TabButton>
{/* Offered once there is a second tab to fall back to —
- removing the only one just left a blank tabs block the
+ removing the only one just left a blank tabs widget the
effect above would immediately refill, which reads as the
control having silently done nothing. */}
{panes.length > 1 && (
@@ -214,7 +214,7 @@ export default function TabsBlock({
placement="bottom"
dataTest={`tab-remove-${paneId}`}
icon={<Icons.CloseOutlined iconSize="s" />}
- onClick={() => provider.removeBuildingBlock(paneId)}
+ onClick={() => provider.removeWidget(paneId)}
/>
)}
</TabItem>
@@ -230,7 +230,7 @@ export default function TabsBlock({
/>
</TabBar>
{/* `activeTabId` is only briefly undefined, on the very first render
- before the layout effect above fills the tabs block in — skipping
+ before the layout effect above fills the tabs widget in — skipping
`FlowContent` for that one tick is what keeps this from ever
needing a pane id it does not have yet. */}
{activeTabId && (
diff --git a/superset-frontend/src/core/dashboard/blocks/flowContent.tsx b/superset-frontend/src/core/dashboard/widgets/flowContent.tsx
similarity index 83%
rename from superset-frontend/src/core/dashboard/blocks/flowContent.tsx
rename to superset-frontend/src/core/dashboard/widgets/flowContent.tsx
index 3321584f09c..1d2651f62b7 100644
--- a/superset-frontend/src/core/dashboard/blocks/flowContent.tsx
+++ b/superset-frontend/src/core/dashboard/widgets/flowContent.tsx
@@ -26,7 +26,7 @@
* `collapsible`, one of several panes for `tabs`/`carousel`) — that part is
* genuinely each container's own business (composition/layout design doc).
* What they do not each reimplement is what a *single* flow area is once
- * you have picked one: a resizable stack of blocks, droppable from the
+ * you have picked one: a resizable stack of widgets, droppable from the
* palette, exactly like `RootGrid`'s own drop target for the reason given
* on `FlowContent` below.
*/
@@ -37,35 +37,35 @@ import { css, styled } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components';
import { provider } from '../store';
import { PALETTE_MIME, placeBlock } from '../placement';
-import BuildingBlockView from '../BuildingBlockView';
+import WidgetView from '../WidgetView';
/**
* The height `FlowItem` falls back to only when it has to measure *something*
* and nothing has rendered yet to measure (see `currentHeight` there) — not
- * a block's actual starting height any more. A block with no `layout.rowSpan`
+ * a widget's actual starting height any more. A widget with no `layout.rowSpan`
* of its own (nothing has resized it yet) flexes to fill whatever room the
* flow area actually has instead, which is a real available-height number,
* not a guess at one.
*/
export const DEFAULT_FLOW_ITEM_HEIGHT = 360;
-/** How short a resize may make a flowed block — short of this and there is nothing left to grab the handle off of. */
+/** How short a resize may make a flowed widget — short of this and there is nothing left to grab the handle off of. */
export const MIN_FLOW_ITEM_HEIGHT = 120;
-/** How far one arrow press resizes a block. */
+/** How far one arrow press resizes a widget. */
const RESIZE_STEP = 16;
/**
- * A flowed block's own resize handle.
+ * A flowed widget's own resize handle.
*
- * `BuildingBlockView` renders whatever it is given as `children` last, after
- * its own header and content — this needs to sit inside the block's own box,
- * on top of it, without becoming part of what the block itself renders, and
- * that slot is the one place that's true for any block, built-in or
+ * `WidgetView` renders whatever it is given as `children` last, after
+ * its own header and content — this needs to sit inside the widget's own box,
+ * on top of it, without becoming part of what the widget itself renders, and
+ * that slot is the one place that's true for any widget, built-in or
* extension-contributed.
*
* A strip along the whole bottom edge rather than a corner square: this
- * resizes one axis, not two (a flow's blocks are already full width, so
+ * resizes one axis, not two (a flow's widgets are already full width, so
* there is no second dimension to change), and a full-width strip is a
* wider target than a handful of pixels in a corner would be.
*/
@@ -104,7 +104,7 @@ const ResizeGrip = styled.div`
`;
/**
- * One block, flowed into an area, with its own height and a way to change
+ * One widget, flowed into an area, with its own height and a way to change
* it.
*
* The height rendered is `layout.rowSpan` once an author has set one —
@@ -115,10 +115,10 @@ const ResizeGrip = styled.div`
* way a grid container reads it in row tracks (see the composition/layout
* design doc — a container's own arrangement is its own business).
*
- * `height` (and `liveHeight`, its local draft) is `undefined` for a block
+ * `height` (and `liveHeight`, its local draft) is `undefined` for a widget
* nobody has resized yet — rather than defaulting it to some fixed number,
* this flexes (`flex: 1 1 auto`) to fill whatever the flow area actually has
- * available, the same way the very first block dropped into an empty area
+ * available, the same way the very first widget dropped into an empty area
* should read as filling it rather than sitting in a corner of it. The
* moment an author (or the resize handle below) sets an explicit height,
* that becomes authoritative and this switches to a fixed one instead
@@ -146,10 +146,10 @@ export function FlowItem({
const wrapperRef = useRef<HTMLDivElement>(null);
// A resize gesture always needs a starting height to measure a delta
- // against — for a block that flexed to fill its space rather than being
+ // against — for a widget that flexed to fill its space rather than being
// given one, that is only ever knowable by measuring what got rendered,
// never by reading `liveHeight` (which is `undefined` for exactly this
- // block). `|| DEFAULT_FLOW_ITEM_HEIGHT` rather than `??`: a measured `0`
+ // widget). `|| DEFAULT_FLOW_ITEM_HEIGHT` rather than `??`: a measured `0`
// (nothing painted yet to measure, or a collapsed flex box) is exactly as
// unusable a starting point for a resize as no measurement at all.
const currentHeight = (): number => {
@@ -161,13 +161,13 @@ export function FlowItem({
};
const startDrag = (event: PointerEvent<HTMLDivElement>): void => {
- // This block sits inside the root's own grid (the flow's own container
+ // This widget sits inside the root's own grid (the flow's own container
// is a grid item like any other), which otherwise reads this same
// pointer-down as the start of a drag on that item — the whole
- // container moving on the root grid instead of this one block resizing
- // inside it. `data-block-resize` is `RootGrid`'s own drag-cancel
- // selector's half of the same guard (see `BuildingBlockView`'s
- // `data-block-remove`, which exists for the identical reason).
+ // container moving on the root grid instead of this one widget resizing
+ // inside it. `data-widget-resize` is `RootGrid`'s own drag-cancel
+ // selector's half of the same guard (see `WidgetView`'s
+ // `data-widget-remove`, which exists for the identical reason).
event.stopPropagation();
from.current = { y: event.clientY, height: currentHeight() };
event.currentTarget.setPointerCapture?.(event.pointerId);
@@ -187,7 +187,7 @@ export function FlowItem({
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
// `liveHeight` only turns into a real number once `drag` has actually
// fired at least once — a press and release with no movement in between
- // is not a resize, and must not fix a block that was flexing in place
+ // is not a resize, and must not fix a widget that was flexing in place
// to whatever `currentHeight` happened to measure at that instant.
if (from.current !== null && liveHeight !== undefined) {
provider.updateLayout(nodeId, { rowSpan: liveHeight });
@@ -229,26 +229,23 @@ export function FlowItem({
}
}
>
- <BuildingBlockView
- nodeId={nodeId}
- style={{ width: '100%', height: '100%' }}
- >
+ <WidgetView nodeId={nodeId} style={{ width: '100%', height: '100%' }}>
<ResizeGrip
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="separator"
aria-orientation="horizontal"
- aria-label={t('Resize block')}
+ aria-label={t('Resize widget')}
aria-valuenow={liveHeight}
aria-valuemin={MIN_FLOW_ITEM_HEIGHT}
tabIndex={0}
data-test={`flow-resize-${nodeId}`}
- data-block-resize
+ data-widget-resize
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={endDrag}
onKeyDown={resize}
/>
- </BuildingBlockView>
+ </WidgetView>
</div>
);
}
@@ -262,7 +259,7 @@ const FlowArea = styled.div`
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
/* The area's own inset — the same gutter the root gives its own
- children (see BuildingBlockView), so a block flowed in here reads as
+ children (see WidgetView), so a widget flowed in here reads as
sitting a comfortable distance inside the container rather than
pressed against its edges. */
padding: ${theme.sizeUnit * 4}px;
@@ -270,23 +267,23 @@ const FlowArea = styled.div`
`;
/**
- * One flow area's worth of content: a resizable stack of blocks, a drop
+ * One flow area's worth of content: a resizable stack of widgets, a drop
* target for the palette, and an empty state when there is nothing in it
* yet.
*
* `accepts` gates the drop rather than the caller doing it before ever
- * rendering this — `collapsible`'s one area holds a single block, and
- * disabling the drop once that block exists (rather than never offering a
+ * rendering this — `collapsible`'s one area holds a single widget, and
+ * disabling the drop once that widget exists (rather than never offering a
* drop target at all) is what lets the empty state's own instruction stay
* honest right up until the moment it stops applying.
*
* `data-container-id` is what makes this a valid reparent target for an
- * *existing* block being dragged on the root's own grid, not just new ones
+ * *existing* widget being dragged on the root's own grid, not just new ones
* from the palette — `RootGrid`'s hit-testing looks for this attribute at
* any nesting depth (see its own doc comment), and a flow area answers it
* the same way `RootGrid`'s own grid does. Stopping propagation on drop is
* what keeps the same event from also reaching `RootGrid`'s handler on its
- * way up the tree — without it, a block dropped here would be placed twice,
+ * way up the tree — without it, a widget dropped here would be placed twice,
* once in this area and once on the root.
*/
export function FlowContent({
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
index 96021ed0931..a23b9ae8022 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx
@@ -131,9 +131,9 @@ const TitleInput = styled(Input)`
* client tools, and gone on the next navigation. The root canvas is the only
* node a dashboard-level fact can belong to, so that is where it lives.
*
- * A title is also a `markdown` block an author can place at the top of the
+ * A title is also a `markdown` widget an author can place at the top of the
* canvas, and that stays true — this is a different thing with a different
- * job. That one is content, laid out and arranged like any other block; this
+ * job. That one is content, laid out and arranged like any other widget; this
* one is what the dashboard is called.
*
* The draft commits on blur rather than on every keystroke: a name being
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
index 803051557f6..d007d246335 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx
@@ -102,7 +102,7 @@ const sectionLabel = (
* The panel, and the one thing `size="small"` cannot reach on its own.
*
* A global rule sets `padding: 4px 8px` on every `input[type="text"]` in the
- * app. antd sizes a small input by zeroing its own block padding, so that
+ * app. antd sizes a small input by zeroing its own widget padding, so that
* rule wins on specificity and a field marked `ant-input-sm` still renders at
* the middle height — eight pixels taller than every other input in this
* rail. The sections below write `type="text"` explicitly, which is what puts
@@ -126,7 +126,7 @@ const Panel = styled.div`
`}
`;
-/** How many blocks are on the dashboard, at any depth. */
+/** How many widgets are on the dashboard, at any depth. */
const countBlocks = (id: string): number =>
(provider.getNode(id)?.children ?? []).reduce(
(total, childId) => total + 1 + countBlocks(childId),
@@ -292,7 +292,7 @@ export default function DashboardProperties(): ReactElement {
};
}, []);
- const blocks = countBlocks(root.id);
+ const widgets = countBlocks(root.id);
return (
// One handler for every field that is typed into: each bubbles its blur
@@ -320,7 +320,7 @@ export default function DashboardProperties(): ReactElement {
{/* Filters are a literal nothing rather than a number that moves:
this builder has no concept of one yet, and a count that could
only ever read zero is still the honest answer to what is here. */}
- {`${tn('%s block', '%s blocks', blocks, blocks)}, ${tn(
+ {`${tn('%s widget', '%s widgets', widgets, widgets)}, ${tn(
'%s filter',
'%s filters',
0,
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
index ab598741096..96e8b04f5d9 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
@@ -34,22 +34,20 @@ const mount = () => {
return onAdd;
};
-test('the panel offers building blocks, properties and an outline', () => {
+test('the panel offers widgets, properties and an outline', () => {
mount();
- expect(
- screen.getByRole('tab', { name: 'Building blocks' }),
- ).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Widgets' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Properties' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Outline' })).toBeInTheDocument();
});
-test('building blocks is what you start on, and it lists what is registered', () => {
+test('widgets is what you start on, and it lists what is registered', () => {
mount();
- // The list is `views.getViews('dashboard.buildingBlocks')` — the same
- // registry BuildingBlockView resolves a renderer through. Nothing here
- // names a block, so registering one makes it placeable with no edit.
+ // The list is `views.getViews('dashboard.widgets')` — the same
+ // registry WidgetView resolves a renderer through. Nothing here
+ // names a widget, so registering one makes it placeable with no edit.
expect(screen.getByTestId('palette')).toBeVisible();
expect(screen.getByTestId('palette-markdown')).toBeVisible();
expect(screen.getByTestId('palette-echarts')).toBeVisible();
@@ -58,9 +56,9 @@ test('building blocks is what you start on, and it lists what is registered', ()
test('the root grid is not offered in the palette, since nesting one is not an authored feature', () => {
mount();
- // The root's own type is resolved directly by `BuildingBlockView`, never
- // through the `dashboard.buildingBlocks` registry this palette lists from
- // (see `registerBuiltInBuildingBlocks`) — so there is nothing registered
+ // The root's own type is resolved directly by `WidgetView`, never
+ // through the `dashboard.widgets` registry this palette lists from
+ // (see `registerBuiltInWidgets`) — so there is nothing registered
// under either name to ever show up here in the first place.
expect(screen.queryByTestId('palette-canvas')).not.toBeInTheDocument();
expect(screen.queryByTestId('palette-grid')).not.toBeInTheDocument();
@@ -69,7 +67,7 @@ test('the root grid is not offered in the palette, since nesting one is not an a
expect(screen.getByTestId('palette-shelf-structure')).toBeInTheDocument();
});
-test('clicking a block asks the page to place it', async () => {
+test('clicking a widget asks the page to place it', async () => {
const onAdd = mount();
await userEvent.click(screen.getByTestId('palette-markdown'));
@@ -86,7 +84,7 @@ test('searching narrows the palette to what was asked for', async () => {
expect(screen.queryByTestId('palette-echarts')).not.toBeInTheDocument();
});
-test('with nothing selected, properties says so rather than showing a stale block', async () => {
+test('with nothing selected, properties says so rather than showing a stale widget', async () => {
mount();
await userEvent.click(screen.getByRole('tab', { name: 'Properties' }));
@@ -96,7 +94,7 @@ test('with nothing selected, properties says so rather than showing a stale bloc
test('selecting something brings its properties forward', () => {
mount();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
});
@@ -109,7 +107,7 @@ test('selecting something brings its properties forward', () => {
test('the outline lists the dashboard and selects what you click', async () => {
mount();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
props: { content: 'Quarterly review' },
});
@@ -128,7 +126,7 @@ test('the outline lists the dashboard and selects what you click', async () => {
test('choosing a row in the outline leaves you in the outline', async () => {
mount();
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
});
await userEvent.click(screen.getByRole('tab', { name: 'Outline' }));
@@ -161,7 +159,7 @@ test('the list of tabs says what it is a list of', () => {
const widthOf = () =>
Number.parseInt(screen.getByTestId('editor-panel').style.width, 10);
-test('the panel opens wide enough to edit a block in', () => {
+test('the panel opens wide enough to edit a widget in', () => {
mount();
expect(widthOf()).toBe(350);
@@ -215,7 +213,7 @@ test('a palette row can actually be dragged, as its grip promises', () => {
);
expect(setData).toHaveBeenCalledWith(
- 'application/x-dashboard-building-block',
+ 'application/x-dashboard-widget',
'markdown',
);
});
@@ -228,14 +226,12 @@ test('the panel can be got out of the way, and brought back', async () => {
// they were in it.
await userEvent.click(screen.getByTestId('panel-collapse'));
- expect(screen.queryByRole('tab', { name: 'Building blocks' })).toBeNull();
+ expect(screen.queryByRole('tab', { name: 'Widgets' })).toBeNull();
expect(screen.getByTestId('panel-expand')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('panel-expand'));
- expect(
- screen.getByRole('tab', { name: 'Building blocks' }),
- ).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Widgets' })).toBeInTheDocument();
});
test('a closed panel keeps the width it was opened at', async () => {
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
index ed292de8f60..518cd004675 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
@@ -27,12 +27,12 @@ import Inspector from './Inspector';
import Outline from './Outline';
import Palette from './Palette';
-type PanelTab = 'blocks' | 'properties' | 'outline';
+type PanelTab = 'widgets' | 'properties' | 'outline';
/**
* How wide the panel opens, and how far it may be dragged.
*
- * The default is set by the Properties tab, which holds a block's whole set
+ * The default is set by the Properties tab, which holds a widget's whole set
* of fields and is the widest thing here; the palette and the outline are
* narrow whatever they are given. The ceiling leaves a usable canvas on a
* small screen.
@@ -127,7 +127,7 @@ const Grip = styled.div<{ $active: boolean }>`
/**
* The authoring panel: one rail, three ways of working on a dashboard.
*
- * Placing a block, editing one and finding one are the same activity at
+ * Placing a widget, editing one and finding one are the same activity at
* different moments, and an author is only ever doing one of them. Giving
* each its own permanent rail would spend the canvas on a choice made moment
* to moment, so they share a rail and the canvas keeps the room.
@@ -138,7 +138,7 @@ export default function EditorPanel({
onAdd: (type: string) => void;
}): ReactElement {
useDashboardRevision();
- const [tab, setTab] = useState<PanelTab>('blocks');
+ const [tab, setTab] = useState<PanelTab>('widgets');
const [width, setWidth] = useState(DEFAULT_WIDTH);
/** Whether the rail is out of the way. The width it had is kept either way. */
const [closed, setClosed] = useState(false);
@@ -151,7 +151,7 @@ export default function EditorPanel({
/**
* Selecting something shows it, and that is a response to the selection
* changing rather than to it existing: an author who goes back to the
- * palette with a block still selected stays there, because nothing changed.
+ * palette with a widget still selected stays there, because nothing changed.
*
* A selection made in the Outline is the exception. Reading a structure
* means going through it, and a tab that ejected on the first row would
@@ -163,7 +163,7 @@ export default function EditorPanel({
const [shown, setShown] = useState(selection);
if (selection !== shown) {
setShown(selection);
- if (selection !== undefined && tab === 'blocks') {
+ if (selection !== undefined && tab === 'widgets') {
setTab('properties');
}
}
@@ -273,7 +273,7 @@ export default function EditorPanel({
size="small"
style={{ flex: 1, minHeight: 0 }}
// Without this, the tab body's own overflow stays `visible` (the
- // component's default) and a tall form or block list bleeds past the
+ // component's default) and a tall form or widget list bleeds past the
// rail's bottom edge instead of scrolling — the rail's `overflow:
// hidden` then clips it silently rather than offering a scrollbar.
allowOverflow={false}
@@ -298,8 +298,8 @@ export default function EditorPanel({
}}
items={[
{
- key: 'blocks',
- label: t('Building blocks'),
+ key: 'widgets',
+ label: t('Widgets'),
children: <Palette onAdd={onAdd} />,
},
{
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
index 09f1e74c8e3..462eb891ea2 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
@@ -23,14 +23,31 @@ import {
screen,
waitFor,
} from 'spec/helpers/testing-library';
+import { SupersetClient } from '@superset-ui/core';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import 'src/core/dashboard';
import Inspector from './Inspector';
+import { resetSchemaControlledWidgetTypesForTests } from './schemaControlledWidgets';
const provider = DashboardProvider.getInstance();
+// The Inspector derives which widget types get the schema-driven panel from
+// `/api/v1/widgets/types`; these tests use markdown/tabs (not in the list), so
+// they render the generic PropsForm. Stub the fetch so the decision is
+// deterministic and offline.
+jest.spyOn(SupersetClient, 'get').mockResolvedValue({
+ json: {
+ result: [
+ { id: 'balloons' },
+ { id: 'metric-tile' },
+ { id: 'ag-grid-table' },
+ ],
+ },
+} as never);
+
beforeEach(() => {
provider.reset();
+ resetSchemaControlledWidgetTypesForTests();
});
/**
@@ -44,7 +61,7 @@ const openJson = async () => {
};
const select = (type: string, props?: Record<string, unknown>) => {
- const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
type,
...(props ? { props } : {}),
});
@@ -53,9 +70,9 @@ const select = (type: string, props?: Record<string, unknown>) => {
return id;
};
-test('a markdown block placed a moment ago can still be given content', async () => {
- // The block arrives with no props at all. Waiting for a `content` key to
- // exist before offering the field is what left a fresh block with no way
+test('a markdown widget placed a moment ago can still be given content', async () => {
+ // The widget arrives with no props at all. Waiting for a `content` key to
+ // exist before offering the field is what left a fresh widget with no way
// to be given one.
const id = select('markdown');
@@ -68,13 +85,13 @@ test('a markdown block placed a moment ago can still be given content', async ()
expect(provider.getNode(id)?.props?.content).toBe('Quarterly review');
});
-test('content a block already has is what the field shows', () => {
+test('content a widget already has is what the field shows', () => {
select('markdown', { content: 'Welcome' });
expect(screen.getByTestId('inspector-content')).toHaveValue('Welcome');
});
-test('a block with no prose field is still authorable through its properties', async () => {
+test('a widget with no prose field is still authorable through its properties', async () => {
select('echarts');
// A chart's dataBinding and echartsOptions have never had a hand-editing
@@ -83,7 +100,7 @@ test('a block with no prose field is still authorable through its properties', a
expect(await openJson()).toBeInTheDocument();
});
-test('applying properties writes them to the block', async () => {
+test('applying properties writes them to the widget', async () => {
const id = select('echarts');
await openJson();
@@ -98,7 +115,7 @@ test('applying properties writes them to the block', async () => {
});
});
-test('a key deleted from the properties stops reaching the block', async () => {
+test('a key deleted from the properties stops reaching the widget', async () => {
const id = select('echarts', { keep: 1, drop: 2 });
await openJson();
@@ -108,9 +125,9 @@ test('a key deleted from the properties stops reaching the block', async () => {
await userEvent.click(screen.getByTestId('inspector-props-apply'));
// `updateProps` merges, so omitting a key would silently do nothing and
- // the block would go on rendering from the value it appeared to lose.
+ // the widget would go on rendering from the value it appeared to lose.
// Sending `undefined` is as close to a removal as a merge can express: the
- // block reads nothing there, and the key does not survive serialization
+ // widget reads nothing there, and the key does not survive serialization
// back into the editor.
expect(provider.getNode(id)?.props?.drop).toBeUndefined();
expect(provider.getNode(id)?.props?.keep).toBe(1);
@@ -163,19 +180,19 @@ test('properties can be edited as a form or as JSON, whichever suits', async ()
expect(await openJson()).toBeInTheDocument();
});
-test('the form is built from the properties the block is actually holding', async () => {
+test('the form is built from the properties the widget is actually holding', async () => {
select('echarts', { title: 'Revenue', limit: 10 });
const form = await openForm();
- // No block type is named anywhere in this panel, so a contributed block
+ // No widget type is named anywhere in this panel, so a contributed widget
// gets a form on the same terms a built-in one does.
expect(form).toHaveTextContent('Title');
expect(form).toHaveTextContent('Limit');
expect(screen.getByDisplayValue('Revenue')).toBeInTheDocument();
});
-test('a value typed into the form reaches the block', async () => {
+test('a value typed into the form reaches the widget', async () => {
const id = select('echarts', { title: 'Revenue' });
await openForm();
@@ -212,7 +229,7 @@ test('the properties on screen can be taken away as JSON', async () => {
select('echarts', { title: 'Revenue' });
await openJson();
- // What is copied is what is on screen, not what the block holds — an edit
+ // What is copied is what is on screen, not what the widget holds — an edit
// typed but not applied yet is the state most worth being able to take
// somewhere else.
fireEvent.change(screen.getByTestId('inspector-props'), {
@@ -229,7 +246,7 @@ test("the dashboard-wide properties are the dashboard's alone", async () => {
select('echarts', { title: 'Revenue' });
// What a dashboard is called, who may see it and how often it refreshes are
- // properties of the dashboard, not of anything placed on it — a block asked
+ // properties of the dashboard, not of anything placed on it — a widget asked
// for a URL slug would be asking for something it has no such thing as.
expect(screen.queryByTestId('dashboard-properties')).not.toBeInTheDocument();
[
@@ -242,11 +259,11 @@ test("the dashboard-wide properties are the dashboard's alone", async () => {
].forEach(section =>
expect(screen.queryByText(section)).not.toBeInTheDocument(),
);
- // And what a block does have stays where it is.
+ // And what a widget does have stays where it is.
expect(await openForm()).toBeInTheDocument();
});
-test('a block with no properties yet says where they are added', async () => {
+test('a widget with no properties yet says where they are added', async () => {
select('echarts');
// A form generated from values cannot offer a field for a key nothing has
@@ -256,7 +273,22 @@ test('a block with no properties yet says where they are added', async () => {
expect(form).toHaveTextContent('JSON');
});
-test('reverting restores what the block still has', async () => {
+test('the Text editor owns content, so the form does not repeat it', async () => {
+ select('markdown', { content: 'Welcome' });
+
+ // The dedicated Text editor shows the prose.
+ expect(screen.getByTestId('inspector-content')).toHaveValue('Welcome');
+ // The generic form must not offer `content` again as a single-line input
+ // mirroring that textarea — with content its only prop, it has nothing left.
+ const form = await openForm();
+ expect(form).toHaveTextContent('no properties yet');
+ // The JSON tab still shows the whole record, content included.
+ expect(await openJson()).toHaveValue(
+ JSON.stringify({ content: 'Welcome' }, null, 2),
+ );
+});
+
+test('reverting restores what the widget still has', async () => {
select('echarts', { kept: true });
await openJson();
@@ -274,7 +306,7 @@ test('the panel is set down from the tabs above it', () => {
select('markdown');
// Flush against the tab bar, the first line reads as a caption belonging
- // to the tabs rather than to the block it names.
+ // to the tabs rather than to the widget it names.
expect(screen.getByTestId('inspector')).toHaveStyle('padding-top: 12px');
});
@@ -318,10 +350,10 @@ test('selecting the dashboard offers the properties the dashboard has', () => {
].forEach(section => expect(screen.getByText(section)).toBeInTheDocument());
});
-test('the dashboard is not a block, so it is not placed and cannot be deleted', () => {
+test('the dashboard is not a widget, so it is not placed and cannot be deleted', () => {
selectRoot();
- // `removeBuildingBlock` refuses the root outright, so a Delete there is a
+ // `removeWidget` refuses the root outright, so a Delete there is a
// control that only ever raises; and the root is placed by nothing, so it
// has no column or row of its own to start at.
expect(screen.queryByTestId('inspector-delete')).not.toBeInTheDocument();
@@ -333,21 +365,21 @@ test('the dashboard is not a block, so it is not placed and cannot be deleted',
test('the panel counts what is on the dashboard', () => {
const rootId = provider.getRoot().id;
- const section = provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
- provider.addBuildingBlock(section, 0, { type: 'markdown' });
- provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
+ const section = provider.addWidget(rootId, 0, { type: 'tabs' });
+ provider.addWidget(section, 0, { type: 'markdown' });
+ provider.addWidget(rootId, 1, { type: 'markdown' });
selectRoot();
- // Every block, at any depth — a section and what is inside it are both
+ // Every widget, at any depth — a section and what is inside it are both
// things on the dashboard.
expect(screen.getByTestId('dashboard-properties-counts')).toHaveTextContent(
- '3 blocks, 0 filters',
+ '3 widgets, 0 filters',
);
});
test('a child is asked where it starts', () => {
const rootId = provider.getRoot().id;
- const childId = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
+ const childId = provider.addWidget(rootId, 0, { type: 'markdown' });
provider.setSelection(childId);
render(<Inspector />);
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
index d6bd82e0abf..fc7de780e78 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect, useState } from 'react';
+import { lazy, Suspense, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { t } from '@apache-superset/core/translation';
@@ -27,14 +27,23 @@ import {
Form,
Input,
InputNumber,
+ Loading,
Tabs,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import copyTextToClipboard from 'src/utils/copy';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
-import { blockLabel } from 'src/core/dashboard/blockLabel';
+import { widgetLabel } from 'src/core/dashboard/widgetLabel';
import DashboardProperties from './DashboardProperties';
import PropsForm from './PropsForm';
+import { useSchemaControlledWidgetTypes } from './schemaControlledWidgets';
+
+// Lazy so the JSONForms / semanticLayers graph the schema-driven control panel
+// pulls in stays out of the eagerly-loaded Inspector bundle (the same
+// core->features cycle that otherwise surfaces app-wide as `t is not a
+// function`). Loaded only when a schema-controlled widget is selected; the
+// dependency-free `schemaControlledWidgets` decides membership.
+const SchemaControlPanel = lazy(() => import('./SchemaControlPanel'));
type LayoutProps = dashboardApi.LayoutProps;
@@ -83,7 +92,7 @@ const GroupTitle = styled.h4`
`}
`;
-/** Where the panel ends, and the one control that ends the block with it. */
+/** Where the panel ends, and the one control that ends the widget with it. */
const Footer = styled.div`
${({ theme }) => css`
margin-top: ${theme.sizeUnit * 4}px;
@@ -162,7 +171,7 @@ const NumberField = ({
};
/**
- * Block types whose renderer reads a plain-text `content` prop.
+ * Widget types whose renderer reads a plain-text `content` prop.
*
* A convenience over the general props editor below, not a special case in
* the render path: prose is miserable to write inside a JSON string, with
@@ -171,7 +180,7 @@ const NumberField = ({
*/
const PLAIN_TEXT_CONTENT = new Set(['markdown']);
-/** The `content` a block renders, edited where it is displayed. */
+/** The `content` a widget renders, edited where it is displayed. */
const ContentField = ({
nodeId,
content,
@@ -213,13 +222,13 @@ const format = (props: Record<string, unknown> | undefined): string =>
const COPIED_FOR_MS = 1500;
/**
- * Everything a block renders from, offered whole and as text.
+ * Everything a widget renders from, offered whole and as text.
*
- * This is the general answer to "how do I give this block its content", and
+ * This is the general answer to "how do I give this widget its content", and
* it is general on purpose: a chart's `dataBinding` and `echartsOptions`, a
- * table's `columnDefs`, and whatever an extension's block reads next year
- * are all just keys here. A form per block type would need this panel to
- * learn every type — the exact knowledge `BuildingBlockView` is built not to
+ * table's `columnDefs`, and whatever an extension's widget reads next year
+ * are all just keys here. A form per widget type would need this panel to
+ * learn every type — the exact knowledge `WidgetView` is built not to
* have, and what `PropsForm` generates a form without needing.
*
* This half is where the *shape* is decided, which is why it survives having
@@ -227,11 +236,11 @@ const COPIED_FOR_MS = 1500;
* be added by writing it.
*
* The draft is held until it parses and the author asks for it, so malformed
- * JSON never reaches a block. What is applied is the whole record: keys the
+ * JSON never reaches a widget. What is applied is the whole record: keys the
* author deleted are sent as `undefined`, which is as close to a removal as
- * a merge can express — the block reads `undefined` either way, and the key
+ * a merge can express — the widget reads `undefined` either way, and the key
* does not survive the next serialization back into this editor. Without
- * that, deleting a line here would silently do nothing and the block would
+ * that, deleting a line here would silently do nothing and the widget would
* go on rendering from the value it appeared to lose.
*/
const PropsJsonEditor = ({
@@ -335,7 +344,7 @@ const PropsJsonEditor = ({
</Button>
{/* Set apart from the two beside it, because it is not one of them:
those commit what is in the box and this only takes a copy of it.
- The draft rather than what the block holds, so what is copied is
+ The draft rather than what the widget holds, so what is copied is
what is on screen — including an edit not applied yet.
Confirmed in place: a panel this narrow has nowhere to put a
message, and a copy that says nothing leaves you pressing it
@@ -378,7 +387,7 @@ const PropsJsonEditor = ({
* The form comes first and is what the panel opens on: it is the half that
* asks a question rather than handing over a record to edit, and most of what
* an author does here is change a value that already exists. The one case it
- * cannot serve — a block placed a moment ago, with no properties and so no
+ * cannot serve — a widget placed a moment ago, with no properties and so no
* fields — says so and names the tab that can, rather than leaving a blank
* pane that reads as broken.
*
@@ -387,16 +396,23 @@ const PropsJsonEditor = ({
* `Form.Item name={...}`, and an antd `Form` above them binds those items to
* its store — which means antd supplies the `value` and the `onChange`,
* overriding the ones JsonForms passed. The field still accepts typing; the
- * edit just goes into a form store nothing reads instead of into the block.
+ * edit just goes into a form store nothing reads instead of into the widget.
* `SemanticLayerModal` renders JsonForms under a plain `<form>` element for
* the same reason.
*/
const PropsEditor = ({
nodeId,
+ widgetType,
props,
+ formOmitKeys,
}: {
nodeId: string;
+ widgetType: string;
props: Record<string, unknown> | undefined;
+ // Keys the generic form must not offer because a dedicated control above it
+ // already edits them (e.g. `content`, owned by the Text editor). Omitted from
+ // the Form tab only — the JSON tab still shows the whole record.
+ formOmitKeys?: readonly string[];
}): ReactElement => {
const theme = useTheme();
// Set down from the tab bar, the same step the panel and the palette take
@@ -406,6 +422,40 @@ const PropsEditor = ({
// beneath it holds.
const inset = { paddingTop: theme.sizeUnit * 3 };
+ // A schema-controlled widget draws its Form tab from a backend-served JSON
+ // Schema (rendered bare — see the note above on why JsonForms must not sit
+ // under an antd `Form`); every other widget falls back to the generic
+ // value-inferred `PropsForm`. Which types are schema-controlled is derived
+ // from the backend (`useSchemaControlledWidgetTypes`), so adding a widget
+ // type needs no frontend edit. Both write to the same `node.props`, and the
+ // JSON tab is identical for all widgets.
+ //
+ // The generic form drops `formOmitKeys` so it doesn't re-offer a value a
+ // dedicated control already edits (a single-line input mirroring the Text
+ // area above it). `updateProps` merges, so a key absent from the form's data
+ // is left untouched rather than removed.
+ const schemaControlledTypes = useSchemaControlledWidgetTypes();
+ const formProps =
+ formOmitKeys && formOmitKeys.length > 0 && props
+ ? Object.fromEntries(
+ Object.entries(props).filter(([key]) => !formOmitKeys.includes(key)),
+ )
+ : props;
+ let formTab: ReactNode;
+ if (schemaControlledTypes === null) {
+ // The backend list is still loading — show a spinner rather than briefly
+ // rendering the wrong control (generic form for a schema-driven widget).
+ formTab = <Loading position="inline-centered" size="s" />;
+ } else if (schemaControlledTypes.has(widgetType)) {
+ formTab = (
+ <Suspense fallback={<Loading position="inline-centered" size="s" />}>
+ <SchemaControlPanel nodeId={nodeId} />
+ </Suspense>
+ );
+ } else {
+ formTab = <PropsForm nodeId={nodeId} props={formProps} />;
+ }
+
return (
<Tabs
size="small"
@@ -415,11 +465,7 @@ const PropsEditor = ({
{
key: 'form',
label: t('Form'),
- children: (
- <div style={inset}>
- <PropsForm nodeId={nodeId} props={props} />
- </div>
- ),
+ children: <div style={inset}>{formTab}</div>,
},
{
key: 'json',
@@ -471,20 +517,20 @@ export default function Inspector(): ReactElement {
image="empty.svg"
title={t('Nothing selected')}
description={t(
- 'Pick a block on the canvas, or a row in the Outline, to edit it here.',
+ 'Pick a widget on the canvas, or a row in the Outline, to edit it here.',
)}
/>
</div>
);
}
- // The root is the dashboard rather than a block on it, so what it is asked
+ // The root is the dashboard rather than a widget on it, so what it is asked
// for is different in kind: what it is called, who it belongs to, how it
// looks — not where it sits or what it renders.
const isRoot = node.id === provider.getRoot().id;
const content = node.props?.content;
- // Offered for a block whose renderer reads prose, whether or not it has
- // any yet — a markdown block placed a moment ago has no props at all, and
+ // Offered for a widget whose renderer reads prose, whether or not it has
+ // any yet — a markdown widget placed a moment ago has no props at all, and
// waiting for a `content` key to exist before showing the field is what
// left it with no way to be given one.
const takesText =
@@ -496,13 +542,13 @@ export default function Inspector(): ReactElement {
<DashboardProperties />
) : (
// The same shape the dashboard's own panel opens with: what this is,
- // then the smaller print about it. The name is `blockLabel`'s — the
- // one the canvas header and the Outline row already use — so a block
+ // then the smaller print about it. The name is `widgetLabel`'s — the
+ // one the canvas header and the Outline row already use — so a widget
// is called one thing in all three places, and the type and id sit
- // under it as what they are, a fact about the block rather than its
+ // under it as what they are, a fact about the widget rather than its
// name.
<div data-test="inspector-identity">
- <IdentityName>{blockLabel(node.type, node.props)}</IdentityName>
+ <IdentityName>{widgetLabel(node.type, node.props)}</IdentityName>
<IdentityMeta>
{node.type} · {node.id}
</IdentityMeta>
@@ -522,7 +568,15 @@ export default function Inspector(): ReactElement {
/>
</Form>
)}
- <PropsEditor nodeId={node.id} props={node.props} />
+ <PropsEditor
+ nodeId={node.id}
+ widgetType={node.type}
+ props={node.props}
+ // The Text editor above already owns `content` for a text widget, so
+ // the generic form drops it rather than showing a single-line input
+ // mirroring that textarea.
+ formOmitKeys={takesText ? ['content'] : undefined}
+ />
</Section>
)}
@@ -546,11 +600,11 @@ export default function Inspector(): ReactElement {
</Form>
)}
- {/* `removeBuildingBlock` refuses the root, so offering it here would be
+ {/* `removeWidget` refuses the root, so offering it here would be
a button that only ever raises.
Ruled off from the fields above it rather than following them at a
- gap: everything else in this column changes the block, and this is
+ gap: everything else in this column changes the widget, and this is
the one control that ends it. The rule is the same one that divides
the sections, so the panel reads as ending here rather than as
having one more field. */}
@@ -566,9 +620,9 @@ export default function Inspector(): ReactElement {
buttonStyle="danger"
icon={<Icons.DeleteOutlined iconSize="s" />}
data-test="inspector-delete"
- onClick={() => provider.removeBuildingBlock(node.id)}
+ onClick={() => provider.removeWidget(node.id)}
>
- {t('Delete block')}
+ {t('Delete widget')}
</Button>
</Footer>
)}
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx
index 78581a888cb..23ed473fe76 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx
@@ -19,7 +19,7 @@
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
-import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
+import WidgetView from 'src/core/dashboard/WidgetView';
import 'src/core/dashboard';
import Outline from './Outline';
@@ -28,7 +28,7 @@ const provider = DashboardProvider.getInstance();
/**
* Which elements were scrolled to, in order. jsdom has no layout, so the call
* is the only observable part of scrolling — what it was called on is what
- * says the right block was reached for.
+ * says the right widget was reached for.
*/
const scrolled: Element[] = [];
@@ -45,12 +45,12 @@ const mount = () =>
render(
<>
<Outline />
- <BuildingBlockView nodeId={provider.getRoot().id} />
+ <WidgetView nodeId={provider.getRoot().id} />
</>,
);
const addMarkdown = (content: string): string =>
- provider.addBuildingBlock(provider.getRoot().id, 0, {
+ provider.addWidget(provider.getRoot().id, 0, {
type: 'markdown',
props: { content },
});
@@ -61,11 +61,11 @@ test('an empty dashboard says so instead of showing an empty tree', () => {
expect(screen.getByTestId('outline-empty')).toBeInTheDocument();
});
-test('a row is listed for every block, labelled by its content', () => {
+test('a row is listed for every widget, labelled by its content', () => {
const id = addMarkdown('Revenue by region');
mount();
- // Scoped to the row rather than looked for on the page: the block's own
+ // Scoped to the row rather than looked for on the page: the widget's own
// header on the canvas carries the same name, by design, so a bare text
// query would match twice and prove neither.
expect(screen.getByRole('tree')).toBeInTheDocument();
@@ -74,7 +74,7 @@ test('a row is listed for every block, labelled by its content', () => {
);
});
-test('choosing a row selects that block', async () => {
+test('choosing a row selects that widget', async () => {
const id = addMarkdown('Revenue by region');
mount();
@@ -83,22 +83,22 @@ test('choosing a row selects that block', async () => {
expect(provider.getSelection()).toBe(id);
});
-test('choosing a row brings its block into view on the canvas', async () => {
+test('choosing a row brings its widget into view on the canvas', async () => {
const below = addMarkdown('Down the page');
addMarkdown('Up the top');
const { container } = mount();
await userEvent.click(screen.getByTestId(`outline-row-${below}`));
- // The point of the outline is reaching blocks the canvas is worst at
+ // The point of the outline is reaching widgets the canvas is worst at
// offering — including one scrolled out of sight. Selecting it and leaving
- // it off screen marks a block the author cannot see.
+ // it off screen marks a widget the author cannot see.
expect(scrolled).toEqual([
container.querySelector(`[data-node-id="${below}"]`),
]);
});
-test('the keyboard reaches a block the same way the pointer does', () => {
+test('the keyboard reaches a widget the same way the pointer does', () => {
const id = addMarkdown('Revenue by region');
const { container } = mount();
@@ -108,9 +108,9 @@ test('the keyboard reaches a block the same way the pointer does', () => {
expect(scrolled).toEqual([container.querySelector(`[data-node-id="${id}"]`)]);
});
-test('a row whose block is not on screen still selects', async () => {
+test('a row whose widget is not on screen still selects', async () => {
// The outline can outlive the canvas it describes — rendered on its own
- // here, but equally a block behind a collapsed container. Selection is the
+ // here, but equally a widget behind a collapsed container. Selection is the
// part that must not depend on finding an element to scroll.
const id = addMarkdown('Revenue by region');
render(<Outline />);
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
index 2cd27aa0b95..e60fea4934e 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx
@@ -21,7 +21,7 @@ import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components';
import { views } from 'src/core/views';
-import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
+import { DASHBOARD_WIDGETS_LOCATION } from 'src/core/dashboard/resolveWidgetView';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
/** How long a label may run before it is cut. */
@@ -30,7 +30,7 @@ const LABEL_LIMIT = 40;
/**
* What a node is called in the outline.
*
- * A registered block's own name first, because that is what the author chose
+ * A registered widget's own name first, because that is what the author chose
* it by in the palette. Markdown gets its opening words instead — a list of
* five rows all reading "Markdown" identifies nothing, and the content is the
* only thing that tells them apart.
@@ -42,7 +42,7 @@ const labelOf = (type: string, props: Record<string, unknown> | undefined) => {
return text.length > LABEL_LIMIT ? `${text.slice(0, LABEL_LIMIT)}…` : text;
}
const registered = views
- .getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
+ .getViews(DASHBOARD_WIDGETS_LOCATION)
?.find(view => view.id === type);
return registered?.name ?? type;
};
@@ -50,15 +50,15 @@ const labelOf = (type: string, props: Record<string, unknown> | undefined) => {
/**
* Selects a node and shows it where it lives.
*
- * Marking a block as selected is only half of reaching it: the rows this
- * panel exists for are the ones for blocks the canvas is currently not
+ * Marking a widget as selected is only half of reaching it: the rows this
+ * panel exists for are the ones for widgets the canvas is currently not
* offering, and an outline that selected something off screen would leave an
- * author looking at a canvas that appears not to have answered. The block's
- * own element carries `data-node-id` (see `BuildingBlockView`), so the canvas
+ * author looking at a canvas that appears not to have answered. The widget's
+ * own element carries `data-node-id` (see `WidgetView`), so the canvas
* needs no wiring back to here.
*
* `nearest` rather than `center`: this fires on every row, and reading down a
- * list of blocks that are already in view should not move the canvas under
+ * list of widgets that are already in view should not move the canvas under
* them. Nothing happens at all when the element is absent — a node can be in
* the tree without being rendered — and selection has already been set by
* then either way.
@@ -74,9 +74,9 @@ const select = (nodeId: string): void => {
* A node, as a tile to read and to reach through.
*
* The same tile the palette is built from, because the two panels are the same
- * kind of thing seen twice: a tree of blocks, one of blocks you could place
- * and one of blocks you did. A block that is a bordered tile with a name in
- * the Building blocks tab and a bare line of text in the Outline reads as two
+ * kind of thing seen twice: a tree of widgets, one of widgets you could place
+ * and one of widgets you did. A widget that is a bordered tile with a name in
+ * the Widgets tab and a bare line of text in the Outline reads as two
* different kinds of object.
*
* What it does not borrow is the grip: these do not drag. What it adds is
@@ -259,7 +259,7 @@ const Row = ({
* The dashboard's structure, as something to read and to reach into.
*
* The canvas shows what a dashboard looks like; this shows what it is made
- * of. That matters most for exactly the blocks the canvas is worst at
+ * of. That matters most for exactly the widgets the canvas is worst at
* offering — one nested inside a container, one scrolled out of view, one
* sized so small there is nothing to click.
*
@@ -280,7 +280,7 @@ export default function Outline(): ReactElement {
image="empty-dashboard.svg"
title={t('Nothing on the dashboard yet')}
description={t(
- 'Blocks you place show up here, in the order they sit on the canvas.',
+ 'Widgets you place show up here, in the order they sit on the canvas.',
)}
/>
</Panel>
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
index 725cc34be66..0b366173b00 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
@@ -24,24 +24,24 @@ import { css, styled } from '@apache-superset/core/theme';
import { EmptyState, Input } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { useViews } from 'src/core/views';
-import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
+import { DASHBOARD_WIDGETS_LOCATION } from 'src/core/dashboard/resolveWidgetView';
import { isContainerType } from 'src/core/dashboard/DashboardProvider';
import { PALETTE_MIME } from 'src/core/dashboard/placement';
type View = viewsApi.View;
/**
- * Which shelf a block sits on.
+ * Which shelf a widget sits on.
*
* Derived from the one distinction this fork actually records: whether
- * placing the type produces something other blocks can go inside. That is a
+ * placing the type produces something other widgets can go inside. That is a
* checkable property of the node the provider builds, not a category anybody
- * maintains, so a block registered by an extension tomorrow is shelved
+ * maintains, so a widget registered by an extension tomorrow is shelved
* correctly without this file learning its name.
*
* There is deliberately no Extensions shelf. A registered `View` carries an
* id, a name and a description and nothing that says who contributed it, so
- * built-in and extension-contributed blocks are genuinely indistinguishable
+ * built-in and extension-contributed widgets are genuinely indistinguishable
* here. Splitting them on a dotted-id naming convention would be a guess
* dressed as a fact; the shelf can be added the day provenance is.
*/
@@ -61,11 +61,11 @@ export interface PaletteEntry {
}
/**
- * Everything registered as a building block, in the order it was registered.
+ * Everything registered as a widget, in the order it was registered.
*
* No filtering needed for the root's own type: `grid` is not registered
- * here at all (see `registerBuiltInBuildingBlocks`), since the root is not a
- * Building Block — nothing to exclude by name, because it was never in this
+ * here at all (see `registerBuiltInWidgets`), since the root is not a
+ * Widget — nothing to exclude by name, because it was never in this
* list to begin with.
*/
export const paletteEntries = (
@@ -172,7 +172,7 @@ const ShelfButton = styled.button`
`;
/**
- * What ties a shelf to the blocks on it.
+ * What ties a shelf to the widgets on it.
*
* The tiles are indented under their shelf, and indentation alone leaves the
* eye to infer the grouping from an edge that is not drawn. The guide down the
@@ -196,7 +196,7 @@ const Branch = styled.div`
`;
/**
- * A block, as a tile to pick up.
+ * A widget, as a tile to pick up.
*
* A bordered tile rather than a bare row: what these are is a set of things
* that get dragged onto a canvas and become boxes there, and a tile with an
@@ -330,20 +330,20 @@ const Disclosure = ({
);
/**
- * The building blocks, as things to place.
+ * The widgets, as things to place.
*
- * The list is the registry's — `useViews('dashboard.buildingBlocks')`, the
- * same location `BuildingBlockView` resolves a renderer through for anything
- * other than the root. Registering a block makes it placeable and
+ * The list is the registry's — `useViews('dashboard.widgets')`, the
+ * same location `WidgetView` resolves a renderer through for anything
+ * other than the root. Registering a widget makes it placeable and
* unregistering one removes it, with no list here to keep in agreement.
*
- * `useViews` rather than a one-time read: an extension's own building block
+ * `useViews` rather than a one-time read: an extension's own widget
* registers itself only once its remote module has actually loaded, which
* is asynchronous (a network fetch for its bundle, then Module Federation's
* own init) and near-certain to still be in flight on this component's first
* render. A snapshot taken then would permanently miss every
- * extension-contributed block that hadn't finished loading yet — built-ins
- * never hit this because `registerBuiltInBuildingBlocks` runs synchronously
+ * extension-contributed widget that hadn't finished loading yet — built-ins
+ * never hit this because `registerBuiltInWidgets` runs synchronously
* at import time, well before anything here renders.
*/
export default function Palette({
@@ -354,7 +354,7 @@ export default function Palette({
const [query, setQuery] = useState('');
const [closed, setClosed] = useState<ReadonlySet<string>>(new Set());
- const registered = useViews(DASHBOARD_BUILDING_BLOCKS_LOCATION);
+ const registered = useViews(DASHBOARD_WIDGETS_LOCATION);
const entries = useMemo(() => paletteEntries(registered), [registered]);
const found = entries.filter(entry => matches(entry, query));
const searching = query.trim() !== '';
@@ -388,7 +388,7 @@ export default function Palette({
<EmptyState
size="small"
image="filter-results.svg"
- title={t('No matching blocks')}
+ title={t('No matching widgets')}
description={t('Nothing here is called “%s”.', query)}
/>
</div>
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
index a870b0e5df6..967ddf8e439 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx
@@ -166,13 +166,13 @@ const FormShell = styled.div`
`;
/**
- * A block's properties as fields, generated from the values it holds.
+ * A widget's properties as fields, generated from the values it holds.
*
* The other half of this panel edits the same properties as JSON, and the two
* divide cleanly: JSON is where the *shape* is decided — a key added, a key
* dropped — and this is where the values in that shape are filled in. That is
* not a limitation to work around but what a generated form is: with no schema
- * shipped alongside a block's registration (see `inferPropsSchema`), a field
+ * shipped alongside a widget's registration (see `inferPropsSchema`), a field
* can only exist where a value already does.
*
* Edits are written as they are made rather than held until focus leaves.
@@ -233,7 +233,7 @@ export default function PropsForm({
{empty
? note(
t(
- 'This block has no properties yet. Add them on the JSON tab, and they become fields here.',
+ 'This widget has no properties yet. Add them on the JSON tab, and they become fields here.',
),
)
: /* No `uischema`: JsonForms lays out whatever the schema describes,
@@ -246,7 +246,7 @@ export default function PropsForm({
renderers={renderers}
cells={cellRegistryEntries}
// Nothing here is required and nothing is constrained, because the
- // schema was read off values a block already renders from — so a
+ // schema was read off values a widget already renders from — so a
// validation message could only ever be about a type this form
// itself assigned.
validationMode="NoValidation"
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.test.tsx
new file mode 100644
index 00000000000..3ed90e70914
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.test.tsx
@@ -0,0 +1,146 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import DashboardProvider from 'src/core/dashboard/DashboardProvider';
+import 'src/core/dashboard';
+import { fetchQueryData } from 'src/core/dashboard/chartData';
+import SchemaControlPanel from './SchemaControlPanel';
+
+jest.mock('src/core/dashboard/chartData', () => ({
+ fetchQueryData: jest.fn().mockResolvedValue({ columns: [], rows: [] }),
+}));
+
+const fetchQueryDataMock = fetchQueryData as jest.MockedFunction<
+ typeof fetchQueryData
+>;
+
+const provider = DashboardProvider.getInstance();
+
+// A minimal backend schema: one scalar field the generic renderers can draw.
+const SCHEMA = {
+ type: 'object',
+ properties: {
+ prefix: { type: 'string', title: 'Prefix' },
+ },
+};
+
+const postSpy = jest.spyOn(SupersetClient, 'post');
+
+beforeEach(() => {
+ provider.reset();
+ postSpy.mockReset();
+ postSpy.mockResolvedValue({ json: { result: SCHEMA } } as never);
+ fetchQueryDataMock.mockReset();
+ fetchQueryDataMock.mockResolvedValue({ columns: [], rows: [] });
+});
+
+const mount = (type: string, props?: Record<string, unknown>) => {
+ const id = provider.addWidget(provider.getRoot().id, 0, {
+ type,
+ ...(props ? { props } : {}),
+ });
+ render(<SchemaControlPanel nodeId={id} />);
+ return id;
+};
+
+test('fetches the backend control schema for the widget type and renders it', async () => {
+ mount('metric-tile', { dataBinding: { datasetId: 1, metrics: ['count'] } });
+
+ await waitFor(() =>
+ expect(postSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ endpoint: '/api/v1/widgets/type/metric-tile/control-schema',
+ }),
+ ),
+ );
+ // The served field is rendered by the generic JSONForms renderers.
+ expect(await screen.findByText('Prefix')).toBeInTheDocument();
+});
+
+test('posts the current props as control_values so dynamic fields can enrich', async () => {
+ mount('metric-tile', { dataBinding: { datasetId: 7, metrics: ['count'] } });
+
+ await waitFor(() => expect(postSpy).toHaveBeenCalled());
+ expect(postSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ jsonPayload: expect.objectContaining({
+ control_values: expect.objectContaining({
+ dataBinding: { datasetId: 7, metrics: ['count'] },
+ }),
+ }),
+ }),
+ );
+});
+
+test('discovers series from the color dimension (the last dimension by default), not the first', async () => {
+ // Grouped by [name, gender]: the widget colors by the last dimension, so the
+ // customizable series must be the distinct genders — not the many names.
+ fetchQueryDataMock.mockResolvedValue({
+ columns: ['name', 'gender', 'count'],
+ rows: [
+ { name: 'Aaron', gender: 'boy', count: 3 },
+ { name: 'Abigail', gender: 'girl', count: 5 },
+ { name: 'Adam', gender: 'boy', count: 2 },
+ ],
+ });
+
+ mount('balloons', {
+ dataBinding: {
+ datasetId: 1,
+ metrics: ['count'],
+ dimensions: ['name', 'gender'],
+ },
+ });
+
+ await waitFor(() =>
+ expect(postSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ jsonPayload: expect.objectContaining({ series: ['boy', 'girl'] }),
+ }),
+ ),
+ );
+});
+
+test('discovers series from an explicit colorDimension when set', async () => {
+ fetchQueryDataMock.mockResolvedValue({
+ columns: ['name', 'gender', 'count'],
+ rows: [
+ { name: 'Aaron', gender: 'boy', count: 3 },
+ { name: 'Abigail', gender: 'girl', count: 5 },
+ ],
+ });
+
+ mount('balloons', {
+ dataBinding: {
+ datasetId: 1,
+ metrics: ['count'],
+ dimensions: ['name', 'gender'],
+ },
+ colorDimension: 'name',
+ });
+
+ await waitFor(() =>
+ expect(postSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ jsonPayload: expect.objectContaining({ series: ['Aaron', 'Abigail'] }),
+ }),
+ ),
+ );
+});
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx
new file mode 100644
index 00000000000..4282b8de440
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx
@@ -0,0 +1,256 @@
+/**
+ * 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.
+ */
+
+/**
+ * A widget's control panel, driven by a backend-owned JSON Schema.
+ *
+ * The schema is fetched from `/api/v1/widgets`, rendered generically
+ * with JsonForms, and edits are written straight back to the node's `props` —
+ * the same object the widget reads and the same object an assistant's
+ * `updateProps` writes, so a change here and one made in chat are the same edit
+ * by different routes. Data is discovered via the v1 chart-data path
+ * (`fetchQueryData`).
+ *
+ * For widgets with an `x-dynamic` sub-schema (e.g. balloons' per-series
+ * `customize`), the panel discovers the widget's distinct series values from the
+ * query results and posts them back, so the backend can enrich the schema (the
+ * SIP's x-dynamic pattern; ignored by schemas that don't declare it).
+ *
+ * NOTE: must be rendered bare — NOT inside an antd `Form`, which would bind the
+ * generated `Form.Item`s to its own store and swallow the edits. The Inspector
+ * renders it bare for exactly this reason (see `PropsEditor`).
+ */
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { Loading, Typography } from '@superset-ui/core/components';
+import { JsonForms } from '@jsonforms/react';
+import type { JsonSchema } from '@jsonforms/core';
+import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+import {
+ buildUiSchema,
+ sanitizeSchema,
+} from 'src/features/semanticLayers/jsonFormsHelpers';
+import { provider, useDashboardRevision } from 'src/core/dashboard/store';
+import { fetchQueryData } from 'src/core/dashboard/chartData';
+import { schemaControlRenderers } from './schemaControlRenderers';
+
+type DataBindingSpec = dashboardApi.DataBindingSpec;
+type WidgetProps = Record<string, unknown>;
+
+/**
+ * Distinct values of the widget's color dimension, so a schema with an
+ * `x-dynamic` per-series section can enumerate them. This must match the
+ * dimension the widget colors by (its `colorDimension`, or the last grouping
+ * dimension by default — see `BalloonsWidget`), so the customizable series line
+ * up with the balloons on screen. Empty when the binding can't be queried.
+ */
+async function loadSeries(
+ binding: DataBindingSpec,
+ colorDimension: string,
+): Promise<string[]> {
+ const { rows } = await fetchQueryData(binding);
+ const seen: string[] = [];
+ rows.forEach(row => {
+ const value = String(row[colorDimension] ?? '');
+ if (!seen.includes(value)) seen.push(value);
+ });
+ return seen;
+}
+
+async function fetchControlSchema(
+ widgetType: string,
+ controlValues: WidgetProps,
+ series: string[],
+): Promise<JsonSchema> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/control-schema`,
+ jsonPayload: { control_values: controlValues, series },
+ });
+ return (json as { result: JsonSchema }).result;
+}
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body Superset
+ * sends back (same shape `chartData.ts` handles).
+ */
+async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+/** True once a binding has enough to run a grouped query. */
+function canQuery(
+ binding: DataBindingSpec | undefined,
+): binding is DataBindingSpec {
+ return Boolean(
+ binding?.datasetId && binding.metrics?.length && binding.dimensions?.length,
+ );
+}
+
+export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
+ useDashboardRevision();
+ const node = provider.getNode(nodeId);
+ const widgetType = node?.type ?? '';
+ const props = useMemo<WidgetProps>(
+ () => (node?.props as WidgetProps) ?? {},
+ [node?.props],
+ );
+ const binding = props.dataBinding as DataBindingSpec | undefined;
+ const bindingKey = JSON.stringify(binding ?? null);
+ // The dimension whose distinct values become the customizable series: the
+ // explicit `colorDimension` when it's one of the grouping dimensions, else the
+ // last dimension (mirrors BalloonsWidget's default).
+ const dimensions = binding?.dimensions ?? [];
+ const explicitColor = props.colorDimension as string | undefined;
+ const colorDimension =
+ explicitColor && dimensions.includes(explicitColor)
+ ? explicitColor
+ : dimensions[dimensions.length - 1];
+
+ const [series, setSeries] = useState<string[]>([]);
+ const [schema, setSchema] = useState<JsonSchema | null>(null);
+ const [error, setError] = useState<string | null>(null);
+
+ // Discover series once the binding has a grouping dimension; empty otherwise.
+ // Only relevant to schemas that declare an x-dynamic field, but harmless for
+ // the rest (the backend ignores `series` when nothing depends on it).
+ useEffect(() => {
+ if (!canQuery(binding) || !colorDimension) {
+ setSeries([]);
+ return undefined;
+ }
+ let cancelled = false;
+ loadSeries(binding, colorDimension)
+ .then(result => !cancelled && setSeries(result))
+ .catch(() => !cancelled && setSeries([]));
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bindingKey, colorDimension]);
+
+ // (Re)fetch the schema when the widget type or discovered series change. The
+ // series change is what carries an x-dynamic dependency (e.g. a new grouping
+ // dimension) through to a re-enriched schema.
+ const seriesKey = JSON.stringify(series);
+ useEffect(() => {
+ if (!widgetType) return undefined;
+ let cancelled = false;
+ // Clear any stale error from a previous (failed) fetch so a later success
+ // doesn't keep rendering an obsolete failure message.
+ setError(null);
+ fetchControlSchema(widgetType, props, series)
+ .then(result => {
+ if (!cancelled) {
+ setSchema(sanitizeSchema(result));
+ setError(null);
+ }
+ })
+ .catch(async e => {
+ const message = await describeError(e);
+ if (!cancelled) setError(message);
+ });
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [widgetType, seriesKey]);
+
+ // JsonForms seeds its internal state from `data` at mount and does NOT
+ // re-apply later `data` prop changes — so an external edit (e.g. the
+ // assistant updating props) updates the schema-driven bits but not the field
+ // values. Remount the form (via key) when props change from outside, while
+ // skipping the initial mount and our own onChange edits (which would
+ // otherwise drop input focus mid-typing).
+ const propsKey = JSON.stringify(props);
+ const mountedRef = useRef(false);
+ const selfEditRef = useRef(false);
+ const [formKey, setFormKey] = useState(0);
+ useEffect(() => {
+ if (!mountedRef.current) {
+ mountedRef.current = true;
+ return;
+ }
+ if (selfEditRef.current) {
+ selfEditRef.current = false;
+ return;
+ }
+ setFormKey(key => key + 1);
+ }, [propsKey]);
+
+ const uiSchema = useMemo(
+ () => (schema ? buildUiSchema(schema) : undefined),
+ [schema],
+ );
+
+ if (!node) return null;
+
+ return (
+ <>
+ {error && <Typography.Text type="danger">{error}</Typography.Text>}
+ {!schema && !error && <Loading position="inline-centered" size="s" />}
+ {schema && (
+ // Vertical layout via class, not an antd `Form` wrapper: the renderers
+ // read the layout off the nearest `.ant-form` ancestor class, so a real
+ // `Form` here would take the edits with it (see `PropsForm`).
+ <div
+ className="ant-form ant-form-vertical"
+ data-test="schema-control-panel"
+ >
+ <JsonForms
+ key={formKey}
+ schema={schema}
+ uischema={uiSchema}
+ data={props}
+ renderers={schemaControlRenderers}
+ cells={cellRegistryEntries}
+ validationMode="ValidateAndHide"
+ onChange={({ data }) => {
+ if (JSON.stringify(data) !== propsKey) {
+ // Our own edit — don't let the resync effect remount the form
+ // for it (that would drop input focus mid-typing).
+ selfEditRef.current = true;
+ provider.updateProps(nodeId, data as WidgetProps);
+ }
+ }}
+ />
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
index 10ded7d8a61..2de5db6ccc4 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
@@ -36,7 +36,7 @@ test('a blank dashboard can still be reached', async () => {
// empty state names both ways in.
expect(
screen.getByText(
- 'Drag a building block from the panel, or ask the assistant for one.',
+ 'Drag a widget from the panel, or ask the assistant for one.',
),
).toBeInTheDocument();
@@ -61,7 +61,7 @@ test('the page is a header, an editor panel and a canvas', () => {
expect(screen.getByTestId('canvas')).toBeInTheDocument();
});
-test('placing a block from the palette puts it on the dashboard and selects it', async () => {
+test('placing a widget from the palette puts it on the dashboard and selects it', async () => {
renderPage();
await userEvent.click(screen.getByTestId('palette-markdown'));
@@ -75,7 +75,7 @@ test('placing a block from the palette puts it on the dashboard and selects it',
/** A drag payload jsdom's synthetic events do not carry on their own. */
const paletteTransfer = (type: string) => {
- const data = new Map([['application/x-dashboard-building-block', type]]);
+ const data = new Map([['application/x-dashboard-widget', type]]);
return {
types: [...data.keys()],
getData: (key: string) => data.get(key) ?? '',
@@ -136,7 +136,7 @@ function dragOverAt(
fireEvent(el, event);
}
-test('the empty-canvas drop preview is sized like the first block, not the whole canvas', () => {
+test('the empty-canvas drop preview is sized like the first widget, not the whole canvas', () => {
renderPage();
const canvas = screen.getByTestId('empty-canvas');
@@ -172,13 +172,13 @@ test('a drag that ends without ever dropping (cancelled, or released off-canvas)
).not.toBeInTheDocument();
});
-test('dropping a palette block on a blank dashboard places it there', () => {
+test('dropping a palette widget on a blank dashboard places it there', () => {
renderPage();
// `RootGrid`'s own drop target does not exist yet on a blank dashboard —
// this is the one that is actually on screen at that point, and it needs
// the identical handling or the empty state's own instruction to "drag a
- // building block from the panel" is one this element cannot answer.
+ // widget from the panel" is one this element cannot answer.
fireEvent.drop(screen.getByTestId('empty-canvas'), {
dataTransfer: paletteTransfer('markdown'),
});
@@ -188,16 +188,16 @@ test('dropping a palette block on a blank dashboard places it there', () => {
expect(provider.getNode(children[0])?.type).toBe('markdown');
});
-test('a block placed while a container is selected goes inside it', async () => {
+test('a widget placed while a container is selected goes inside it', async () => {
renderPage();
- // A 'tabs' block itself is not the container to select for this — its own
+ // A 'tabs' widget itself is not the container to select for this — its own
// children are always 'tab' panes, never a leaf placed directly — so this
// selects the pane, which is exactly where a leaf placed from the palette
// belongs.
- const tabsId = provider.addBuildingBlock(provider.getRoot().id, 0, {
+ const tabsId = provider.addWidget(provider.getRoot().id, 0, {
type: 'tabs',
});
- const paneId = provider.addBuildingBlock(tabsId, 0, {
+ const paneId = provider.addWidget(tabsId, 0, {
type: 'tab',
props: { label: 'Overview' },
});
@@ -205,13 +205,13 @@ test('a block placed while a container is selected goes inside it', async () =>
await userEvent.click(screen.getByTestId('palette-markdown'));
- // An author who has just selected a pane and reaches for a block means to
+ // An author who has just selected a pane and reaches for a widget means to
// put it in that pane.
expect(provider.getNode(paneId)?.children).toEqual([provider.getSelection()]);
expect(provider.getNode(tabsId)?.children).toEqual([paneId]);
});
-test('a block placed while a leaf is selected goes beside it, not inside it', async () => {
+test('a widget placed while a leaf is selected goes beside it, not inside it', async () => {
renderPage();
await userEvent.click(screen.getByTestId('palette-markdown'));
const firstId = provider.getSelection()!;
@@ -231,7 +231,7 @@ test('clicking the canvas itself clears the selection', async () => {
await userEvent.click(screen.getByTestId('canvas'));
- // A click that reached the canvas passed every block on the way, so it is
+ // A click that reached the canvas passed every widget on the way, so it is
// the one gesture that unambiguously means "nothing".
expect(provider.getSelection()).toBeUndefined();
});
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
index 47cfe235272..35940453547 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
@@ -38,7 +38,7 @@ import {
} from 'src/core/dashboard/layoutStyle';
import { availableDropSpan } from 'src/core/dashboard/gridPacking';
import type { PackedRect } from 'src/core/dashboard/gridPacking';
-import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
+import WidgetView from 'src/core/dashboard/WidgetView';
import DashboardHeader from './DashboardHeader';
import EditorPanel from './EditorPanel';
@@ -94,7 +94,7 @@ const EmptyCanvasWrapper = styled.div`
* The dashboard with nothing on it, as something to aim at.
*
* No border and no hover fill of its own — the root draws directly onto the
- * grid, same as every block on it (see `BuildingBlockView`) — but it is
+ * grid, same as every widget on it (see `WidgetView`) — but it is
* still the only way to select the root on a blank canvas and the palette's
* own drop target, so a Tab still lands on it and takes a visible outline,
* rather than the control being unreachable from the keyboard entirely.
@@ -138,7 +138,7 @@ const CanvasPlaceholder = styled.div`
* `RootGrid`'s own ghost uses. A cursor-following, capped-size preview
* (`FALLBACK_COL_SPAN`/`FALLBACK_ROW_SPAN`, the same cap `availableDropSpan`
* itself now applies to any open-space drop) rather than a full-placeholder
- * or fixed-centered box: a block dropped near the left edge belongs at the
+ * or fixed-centered box: a widget dropped near the left edge belongs at the
* left edge, and one dropped low belongs low, exactly as it would on a grid
* that already has something on it — a blank canvas earns no exception to
* that just because there is nothing yet to be beside.
@@ -156,7 +156,7 @@ const DropPreview = styled.div`
/**
* Prototype entry point for SIP item 7.1 (AI-Native Dashboards, section 7.1
* of the design doc): a canvas paired with the chat panel, so the
- * building-block schema/renderer/platform-API work can be iterated on with a
+ * widget schema/renderer/platform-API work can be iterated on with a
* real natural-language chat loop rather than a mock. Does not persist
* anything yet — layout/style state lives only in memory for this demo.
*
@@ -165,10 +165,10 @@ const DropPreview = styled.div`
* user already has), via the same global ChatPanelHost/ChatFloatingHost
* mounted in App.tsx.
*
- * Rendering the tree itself is entirely delegated to `BuildingBlockView` —
+ * Rendering the tree itself is entirely delegated to `WidgetView` —
* this page owns only its own chrome (the empty state) and knows nothing
* about node types, built-in or extension-contributed alike. There's no
- * page-level title chrome: a title is just a `markdown` building block like
+ * page-level title chrome: a title is just a `markdown` widget like
* any other, placed at the top of the canvas the same way the rest of the
* dashboard's content is.
*/
@@ -182,7 +182,7 @@ export default function DashboardBuilderV2() {
// No `layout` to read yet — a blank root has never had one set — so this
// resolves to the exact same defaults `RootGrid` itself falls back to,
// which is what makes `DropPreview`'s own size below provably the same
- // size the first real block will open at, not a separately-tuned guess.
+ // size the first real widget will open at, not a separately-tuned guess.
const emptyCanvasMetrics = resolveGridMetrics(undefined, theme);
// A counter, not a plain boolean: the placeholder isn't a single element,
@@ -257,7 +257,7 @@ export default function DashboardBuilderV2() {
};
/**
- * Places a block from the palette.
+ * Places a widget from the palette.
*
* Into whatever is selected when that can hold children, and into the root
* otherwise. An author who has just selected a section and reaches for a
@@ -267,7 +267,7 @@ export default function DashboardBuilderV2() {
* A drag from the palette says where for itself — the container it was
* dropped on takes it — so only the click needs a target chosen for it.
* Both then go through the same `placeBlock`, because two copies of what a
- * freshly placed block looks like is how the two paths quietly diverge.
+ * freshly placed widget looks like is how the two paths quietly diverge.
*/
const addBlock = (type: string): void => {
const selected = provider.getSelection();
@@ -287,9 +287,9 @@ export default function DashboardBuilderV2() {
<Canvas
data-test="canvas"
onClick={event => {
- // A click that reached the canvas itself passed every block on
+ // A click that reached the canvas itself passed every widget on
// the way, so it is the one gesture that unambiguously means
- // "nothing". A click on a block stops before here.
+ // "nothing". A click on a widget stops before here.
if (event.target === event.currentTarget) {
provider.setSelection(undefined);
}
@@ -324,7 +324,7 @@ export default function DashboardBuilderV2() {
// since a dashboard with nothing on it yet is exactly when
// this placeholder (rather than `RootGrid`) is what's on
// screen to drop onto. Without this, the empty state's own
- // "Drag a building block from the panel" is an instruction
+ // "Drag a widget from the panel" is an instruction
// this element cannot actually answer.
onDragEnter={event => {
if (event.dataTransfer.types.includes(PALETTE_MIME)) {
@@ -389,14 +389,14 @@ export default function DashboardBuilderV2() {
image="empty-dashboard.svg"
title={t('Start building')}
description={t(
- 'Drag a building block from the panel, or ask the assistant for one.',
+ 'Drag a widget from the panel, or ask the assistant for one.',
)}
/>
)}
</CanvasPlaceholder>
</EmptyCanvasWrapper>
) : (
- <BuildingBlockView nodeId={root.id} />
+ <WidgetView nodeId={root.id} />
)}
</Canvas>
</Workspace>
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts
index 16c57db39ea..2a38994299f 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts
+++ b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts
@@ -18,7 +18,7 @@
*/
import inferPropsSchema, { untypedKeys } from './inferPropsSchema';
-test('each property is typed by the value the block is holding', () => {
+test('each property is typed by the value the widget is holding', () => {
const schema = inferPropsSchema({
title: 'Revenue',
limit: 10,
@@ -92,7 +92,7 @@ test('a property holding nothing is left out rather than given a type it has not
expect(untypedKeys({ kept: 'yes', cleared: null })).toEqual(['cleared']);
});
-test('a block with no properties has an empty schema rather than no schema', () => {
+test('a widget with no properties has an empty schema rather than no schema', () => {
// JsonForms is handed this either way; an absent `properties` throws where
// an empty one renders nothing.
expect(inferPropsSchema(undefined)).toEqual({
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts
index 6efe1fc07ff..559c1067d7e 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts
+++ b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts
@@ -51,16 +51,16 @@ function describe(value: unknown): JsonSchema7 | undefined {
}
/**
- * A block's properties, described as a JSON Schema so they can be edited in a
+ * A widget's properties, described as a JSON Schema so they can be edited in a
* form instead of in a string of JSON.
*
- * Read off the values rather than declared per block type, and deliberately
- * so: `BuildingBlockView` resolves a renderer through a registry an extension
+ * Read off the values rather than declared per widget type, and deliberately
+ * so: `WidgetView` resolves a renderer through a registry an extension
* writes into, and a schema per type would make this panel the one place that
* has to learn every type there is — the exact knowledge the render path is
* built not to have. A schema shipped alongside each registration would be
* better still, and this is what stands in until there is one: it describes
- * whatever the block is holding, built-in or contributed, with no list to
+ * whatever the widget is holding, built-in or contributed, with no list to
* keep current.
*
* What it cannot do is invent a key that is not there. A property nothing has
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx
new file mode 100644
index 00000000000..9cc97d8e0b4
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx
@@ -0,0 +1,124 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Extra JSONForms renderers for schema-driven widget controls, on top
+ * of the Semantic Layer's shared `renderers`:
+ * - `x-control: code` → a raw JSON editor (e.g. AG Grid column definitions).
+ * - `x-control: color` → a native color swatch (e.g. per-series balloon color).
+ *
+ * Both fall back to the field's schema `default`, since JsonForms does not
+ * write defaults into the data until a field is touched.
+ */
+import { useState } from 'react';
+import type { ChangeEvent } from 'react';
+import { withJsonFormsControlProps } from '@jsonforms/react';
+import type { ControlProps, JsonSchema } from '@jsonforms/core';
+import { rankWith, schemaMatches } from '@jsonforms/core';
+import { Flex, Typography } from '@superset-ui/core/components';
+import { useTheme } from '@apache-superset/core/theme';
+import { renderers as baseRenderers } from 'src/features/semanticLayers/jsonFormsHelpers';
+
+const xControlIs = (value: string) =>
+ schemaMatches(
+ (schema: JsonSchema) =>
+ (schema as Record<string, unknown>)['x-control'] === value,
+ );
+
+/** Raw JSON editor for an `x-control: code` field. */
+function CodeControl({
+ data,
+ handleChange,
+ path,
+ schema,
+ label,
+}: ControlProps) {
+ const schemaRecord = schema as Record<string, unknown>;
+ // Fall back to the schema default, or an empty container of the right kind
+ // (`[]` for an array field like `metrics`, `{}` otherwise) so the editor
+ // opens on a valid, type-appropriate value rather than `{}` for an array.
+ const fallback =
+ schemaRecord.default ?? (schemaRecord.type === 'array' ? [] : {});
+ const [text, setText] = useState(() =>
+ JSON.stringify(data ?? fallback, null, 2),
+ );
+ const [error, setError] = useState<string | null>(null);
+
+ const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
+ const next = event.target.value;
+ setText(next);
+ try {
+ handleChange(path, JSON.parse(next));
+ setError(null);
+ } catch {
+ setError('Invalid JSON — not applied');
+ }
+ };
+
+ return (
+ <Flex vertical gap="small">
+ <Typography.Text strong>{label}</Typography.Text>
+ <textarea
+ value={text}
+ onChange={onChange}
+ rows={12}
+ spellCheck={false}
+ style={{ fontFamily: 'monospace', fontSize: 12, width: '100%' }}
+ />
+ {error && <Typography.Text type="danger">{error}</Typography.Text>}
+ </Flex>
+ );
+}
+
+/** Native color picker for an `x-control: color` string field. */
+function ColorControl({
+ data,
+ handleChange,
+ path,
+ schema,
+ label,
+}: ControlProps) {
+ const theme = useTheme();
+ const value = (data ??
+ (schema as Record<string, unknown>).default ??
+ theme.colorText) as string;
+ return (
+ <Flex align="center" gap="small">
+ <input
+ type="color"
+ value={value}
+ onChange={event => handleChange(path, event.target.value)}
+ />
+ <Typography.Text>{label}</Typography.Text>
+ </Flex>
+ );
+}
+
+/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+export const schemaControlRenderers = [
+ ...baseRenderers,
+ {
+ tester: rankWith(1000, xControlIs('code')),
+ renderer: withJsonFormsControlProps(CodeControl),
+ },
+ {
+ tester: rankWith(1000, xControlIs('color')),
+ renderer: withJsonFormsControlProps(ColorControl),
+ },
+];
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.test.ts b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.test.ts
new file mode 100644
index 00000000000..32973e9f9e4
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.test.ts
@@ -0,0 +1,70 @@
+/**
+ * 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 { renderHook, waitFor } from '@testing-library/react';
+import { SupersetClient } from '@superset-ui/core';
+import {
+ resetSchemaControlledWidgetTypesForTests,
+ useSchemaControlledWidgetTypes,
+} from './schemaControlledWidgets';
+
+const getSpy = jest.spyOn(SupersetClient, 'get');
+
+beforeEach(() => {
+ resetSchemaControlledWidgetTypesForTests();
+ getSpy.mockReset();
+});
+
+test('derives the schema-controlled types from the backend registry', async () => {
+ getSpy.mockResolvedValue({
+ json: { result: [{ id: 'balloons' }, { id: 'metric-tile' }] },
+ } as never);
+
+ const { result } = renderHook(() => useSchemaControlledWidgetTypes());
+
+ // `null` while the first fetch is in flight, so the caller can show Loading.
+ expect(result.current).toBeNull();
+
+ await waitFor(() => expect(result.current).not.toBeNull());
+ expect(result.current?.has('balloons')).toBe(true);
+ expect(result.current?.has('metric-tile')).toBe(true);
+ // A type the backend didn't report gets the generic form, not a schema panel.
+ expect(result.current?.has('markdown')).toBe(false);
+ expect(getSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: '/api/v1/widgets/types' }),
+ );
+});
+
+test('fetches only once and caches the result across hook users', async () => {
+ getSpy.mockResolvedValue({ json: { result: [{ id: 'balloons' }] } } as never);
+
+ const first = renderHook(() => useSchemaControlledWidgetTypes());
+ await waitFor(() => expect(first.result.current).not.toBeNull());
+ renderHook(() => useSchemaControlledWidgetTypes());
+
+ expect(getSpy).toHaveBeenCalledTimes(1);
+});
+
+test('fails open to an empty set when the request errors', async () => {
+ getSpy.mockRejectedValue(new Error('boom'));
+
+ const { result } = renderHook(() => useSchemaControlledWidgetTypes());
+
+ await waitFor(() => expect(result.current).not.toBeNull());
+ expect(result.current?.size).toBe(0);
+});
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.ts b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.ts
new file mode 100644
index 00000000000..1c709e1044a
--- /dev/null
+++ b/superset-frontend/src/pages/DashboardBuilderV2/schemaControlledWidgets.ts
@@ -0,0 +1,87 @@
+/**
+ * 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.
+ */
+
+/**
+ * Which widget types own a backend-served, schema-driven control panel —
+ * derived from the backend registry, not hand-maintained here.
+ *
+ * The list is whatever `/api/v1/widgets/types` reports (the widget-controls
+ * registry), fetched once and cached. Adding a widget type — built-in or from
+ * an extension — therefore needs no edit to this file; the Inspector picks it
+ * up automatically and falls back to the generic value-inferred `PropsForm` for
+ * any type the backend has no schema for.
+ *
+ * Intentionally a tiny, dependency-free module (only `SupersetClient` +
+ * `react`): the Inspector imports it eagerly to decide whether to render
+ * `SchemaControlPanel` (lazily loaded). Keeping the JSONForms / `semanticLayers`
+ * graph out of this module is what lets the Inspector stay in the eager bundle
+ * without dragging that graph in (the core->features import cycle that
+ * otherwise surfaces app-wide as `t is not a function`).
+ */
+import { useSyncExternalStore } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+
+// `null` until the first fetch resolves; a Set of widget-type ids thereafter.
+let types: ReadonlySet<string> | null = null;
+let inFlight: Promise<void> | null = null;
+const listeners = new Set<() => void>();
+
+const emit = () => listeners.forEach(listener => listener());
+
+function ensureLoaded(): void {
+ if (types !== null || inFlight) return;
+ inFlight = SupersetClient.get({ endpoint: '/api/v1/widgets/types' })
+ .then(({ json }) => {
+ const result = (json as { result?: { id: string }[] }).result ?? [];
+ types = new Set(result.map(entry => entry.id));
+ emit();
+ })
+ .catch(() => {
+ // Fail open: on error every widget is treated as having no schema, so the
+ // Inspector shows the generic props form rather than hanging on Loading.
+ types = new Set();
+ emit();
+ });
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+const getSnapshot = (): ReadonlySet<string> | null => types;
+
+/**
+ * The set of widget types that have a backend-served control schema, fetched
+ * once and cached. Returns `null` while the first fetch is in flight so callers
+ * can show a loading state rather than momentarily rendering the wrong control.
+ */
+export function useSchemaControlledWidgetTypes(): ReadonlySet<string> | null {
+ ensureLoaded();
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
+
+/** Reset the module-level cache. Test-only. */
+export function resetSchemaControlledWidgetTypesForTests(): void {
+ types = null;
+ inFlight = null;
+ listeners.clear();
+}
diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py
index f2d48f31853..1706cfb78d4 100644
--- a/superset/core/api/core_api_injection.py
+++ b/superset/core/api/core_api_injection.py
@@ -281,6 +281,69 @@ def inject_semantic_layer_implementations() -> None:
core_sl_module.semantic_layer = semantic_layer_impl # type: ignore[assignment]
+def inject_widget_implementations() -> None:
+ """
+ Replace the abstract widget decorator in ``superset_core.widgets.decorators``
+ with a concrete implementation that registers widget control sets in the
+ host registry, then register the built-in widgets.
+
+ Registration raises on a genuine ``widget_type`` collision (naming both the
+ existing and the new class) so a careless or malicious extension cannot
+ silently replace another widget's schema; extension registrations are
+ namespaced by publisher/name, mirroring semantic layers.
+
+ Built-ins are imported here — *after* the decorator is concrete — because
+ ``superset.widgets.api`` is imported during ``init_views()``, which runs
+ before this injection. This mirrors how the MCP layer imports its host tool
+ modules only after injecting ``@tool``.
+ """
+ import superset_core.widgets as core_widgets_pkg
+ import superset_core.widgets.decorators as core_widgets_decorators
+
+ from superset.widgets.registry import registry
+
+ def widget_impl(
+ widget_type: str,
+ name: str,
+ description: str | None = None,
+ ) -> Callable[[Any], Any]:
+ def decorator(cls: Any) -> Any:
+ if context := get_current_extension_context():
+ key = (
+ f"extensions.{context.extension.publisher}."
+ f"{context.extension.name}.{widget_type}"
+ )
+ else:
+ key = widget_type
+
+ existing = registry.get(key)
+ if existing is not None and existing is not cls:
+ raise ValueError(
+ f"Widget type {key!r} is already registered by "
+ f"{existing.__module__}.{existing.__qualname__}; "
+ f"{cls.__module__}.{cls.__qualname__} cannot replace it."
+ )
+
+ cls.widget_type = key
+ cls.name = name
+ cls.description = description or ""
+ registry[key] = cls
+ return cls
+
+ return decorator
+
+ # Rebind on both the decorators module and the package re-export, so a
+ # consumer using either `from superset_core.widgets import widget` or
+ # `from superset_core.widgets.decorators import widget` resolves to the
+ # concrete implementation after injection.
+ core_widgets_decorators.widget = widget_impl # type: ignore[assignment]
+ core_widgets_pkg.widget = widget_impl # type: ignore[assignment]
+
+ # Register the built-in widgets now that the decorator is concrete (their
+ # module applies @widget at import time).
+ import superset.widgets.builtin # noqa: F401 pylint: disable=unused-import
+
+
def inject_storage_implementations() -> None:
"""
Replace abstract storage classes in superset_core.extensions.storage with concrete
@@ -329,5 +392,6 @@ def initialize_core_api_dependencies() -> None:
inject_task_implementations()
inject_rest_api_implementations()
inject_semantic_layer_implementations()
+ inject_widget_implementations()
inject_storage_implementations()
inject_extension_context()
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 36596303daf..a3428368437 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -237,6 +237,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.user_registrations import UserRegistrationsView
from superset.views.users.api import CurrentUserRestApi, UserRestApi
from superset.views.users_list import UsersListView
+ from superset.widgets.api import WidgetControlsRestApi
set_app_error_handlers(self.superset_app)
self.register_request_handlers()
@@ -257,6 +258,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(CacheRestApi)
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(ChartDataRestApi)
+ appbuilder.add_api(WidgetControlsRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(ThemeRestApi)
appbuilder.add_api(CurrentUserRestApi)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index d92d6361d73..230042d6351 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -860,6 +860,10 @@ from superset.mcp_service.user.tool import ( # noqa: F401, E402
get_user_info,
list_users,
)
+from superset.mcp_service.widgets.tool import ( # noqa: F401, E402
+ get_widget_control_schema,
+ list_widget_types,
+)
#: Tool names exempt from the mcp_auth_hook protection check. Adding a tool
#: here is a security-significant choice — review carefully. Entries are tools
diff --git a/superset/mcp_service/widgets/__init__.py b/superset/mcp_service/widgets/__init__.py
new file mode 100644
index 00000000000..f73ab474f2a
--- /dev/null
+++ b/superset/mcp_service/widgets/__init__.py
@@ -0,0 +1,18 @@
+# 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.
+
+"""MCP tools for Dashboard V2 schema-driven widget controls (experimental)."""
diff --git a/superset/mcp_service/widgets/tool/__init__.py b/superset/mcp_service/widgets/tool/__init__.py
new file mode 100644
index 00000000000..bfe8a57ba93
--- /dev/null
+++ b/superset/mcp_service/widgets/tool/__init__.py
@@ -0,0 +1,24 @@
+# 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 .get_widget_control_schema import get_widget_control_schema
+from .list_widget_types import list_widget_types
+
+__all__ = [
+ "list_widget_types",
+ "get_widget_control_schema",
+]
diff --git a/superset/mcp_service/widgets/tool/get_widget_control_schema.py b/superset/mcp_service/widgets/tool/get_widget_control_schema.py
new file mode 100644
index 00000000000..749ecb33feb
--- /dev/null
+++ b/superset/mcp_service/widgets/tool/get_widget_control_schema.py
@@ -0,0 +1,115 @@
+# 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.
+
+"""MCP tool: get_widget_control_schema"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_widget_type_error,
+)
+from superset.widgets.schema_tools import (
+ get_subtrees,
+ prune_to_minimal_viable,
+ SchemaPathError,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _get_widget_control_schema_impl(
+ widget_type: str,
+ paths: List[str] | None = None,
+ control_values: Dict[str, Any] | None = None,
+ series: List[str] | None = None,
+) -> Dict[str, Any]:
+ """Pure logic: the minimum-viable root schema, or the requested subtrees."""
+ widget = resolve_widget(widget_type)
+ if widget is None:
+ return unknown_widget_type_error(widget_type)
+ schema = widget.get_control_schema(control_values, series)
+ if not paths:
+ return prune_to_minimal_viable(schema)
+ try:
+ return {"subtrees": get_subtrees(schema, paths)}
+ except SchemaPathError as ex:
+ return {
+ "error": {
+ "error_type": "invalid_path",
+ "message": str(ex),
+ "suggestions": [
+ "Call this tool without `paths` first, then drill into the "
+ "x-path values of its x-collapsed nodes.",
+ ],
+ }
+ }
+
+
+@tool(
+ tags=["discovery"],
+ class_permission_name="Chart",
+ annotations=ToolAnnotations(
+ title="Get widget control schema",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+def get_widget_control_schema(
+ widget_type: str,
+ paths: List[str] | None = None,
+ control_values: Dict[str, Any] | None = None,
+ series: List[str] | None = None,
+) -> Dict[str, Any]:
+ """Get the control schema for a Dashboard V2 widget type (progressive
+ disclosure).
+
+ Called WITHOUT ``paths``, returns the **minimum viable** root: every
+ mandatory field expanded to its leaves (descending into mandatory nested
+ objects), plus cheap optional leaves inline. Optional nested branches are
+ returned as opaque drill-in markers carrying ``x-collapsed: true`` and an
+ ``x-path``; the root is tagged ``x-disclosure: "minimal"``.
+
+ Called WITH ``paths`` (each an ``x-path`` from a marker, e.g.
+ ``["a", "a/b"]``), returns ``{"subtrees": {<path>: <schema>}}`` — one
+ self-contained JSON Schema per requested path, ``$ref`` inlined. Expand
+ several at once to avoid a round trip per branch.
+
+ The root's mandatory fields alone are enough to build a valid widget. Expand
+ a collapsed branch ONLY when the user's request calls for that optional
+ capability (judge from the marker's ``description``) — do NOT pre-fetch
+ speculatively.
+
+ A marker flagged ``x-dynamic: true`` has a shape that depends on the current
+ query (e.g. a map keyed by a dimension's distinct values). To get its
+ ENRICHED shape you must pass ``control_values`` (the current widget ``props``,
+ so the branch's dependency is satisfied) — and any known ``series`` values.
+ ``series`` on its own does nothing: without a satisfying ``control_values``
+ you get the branch's GENERIC shape instead (e.g. a map of
+ ``<value> -> {...}``), which is usually enough to fill it in anyway. Re-fetch
+ a dynamic branch after the query changes. A marker WITHOUT ``x-dynamic`` is
+ static — expand it once and it won't change.
+
+ Returns a structured error for an unknown ``widget_type`` or an unresolvable
+ ``path``.
+ """
+ return _get_widget_control_schema_impl(widget_type, paths, control_values, series)
diff --git a/superset/mcp_service/widgets/tool/list_widget_types.py b/superset/mcp_service/widgets/tool/list_widget_types.py
new file mode 100644
index 00000000000..50982e7c076
--- /dev/null
+++ b/superset/mcp_service/widgets/tool/list_widget_types.py
@@ -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.
+
+"""MCP tool: list_widget_types"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.utils import registry
+
+logger = logging.getLogger(__name__)
+
+
+def _list_widget_types_impl() -> List[Dict[str, Any]]:
+ """Pure logic: the registered schema-driven widget types."""
+ return [
+ {
+ "id": cls.widget_type,
+ "name": cls.name,
+ "description": cls.description,
+ }
+ for cls in registry.values()
+ ]
+
+
+@tool(
+ tags=["discovery"],
+ class_permission_name="Chart",
+ annotations=ToolAnnotations(
+ title="List dashboard widget types",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+def list_widget_types() -> List[Dict[str, Any]]:
+ """List the Dashboard V2 widget types that have a schema-driven control
+ panel.
+
+ This is the entry point for progressive disclosure: pick a type's ``id``,
+ then call ``get_widget_control_schema`` for its minimum-viable control schema
+ (call it again with ``paths`` to drill into optional/nested branches).
+ Returns each type's id, human name, and description.
+ """
+ return _list_widget_types_impl()
diff --git a/superset/mcp_service/widgets/utils.py b/superset/mcp_service/widgets/utils.py
new file mode 100644
index 00000000000..932e970aa1a
--- /dev/null
+++ b/superset/mcp_service/widgets/utils.py
@@ -0,0 +1,54 @@
+# 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.
+
+"""
+Shared helpers for the Dashboard V2 widget-control MCP tools.
+
+Kept separate from the decorated tool modules so the pure logic is importable
+and unit-testable without going through the MCP layer (mirrors the
+``get_chart_type_schema`` ``_impl`` pattern).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset_core.widgets import Widget
+
+from superset.widgets.registry import registry
+
+
+def valid_widget_types() -> list[str]:
+ return sorted(cls.widget_type for cls in registry.values())
+
+
+def unknown_widget_type_error(widget_type: str) -> dict[str, Any]:
+ """Structured error for an unknown widget type (mirrors the chart tools)."""
+ return {
+ "error": {
+ "error_type": "invalid_widget_type",
+ "message": f"Unknown widget type: {widget_type!r}",
+ "suggestions": [
+ "Call list_widget_types to see the available types.",
+ ],
+ },
+ "valid_widget_types": valid_widget_types(),
+ }
+
+
+def resolve_widget(widget_type: str) -> type[Widget] | None:
+ return registry.get(widget_type)
diff --git a/superset/widgets/__init__.py b/superset/widgets/__init__.py
new file mode 100644
index 00000000000..b353f53642f
--- /dev/null
+++ b/superset/widgets/__init__.py
@@ -0,0 +1,35 @@
+# 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.
+"""
+Schema-driven controls for Dashboard V2 widgets (experimental).
+
+Each data-backed widget type declares its control panel as a Pydantic
+model whose JSON Schema *is* the control panel. That single backend-owned schema
+drives both the human control panel (rendered generically with JSONForms in the
+dashboard Inspector) and read-only MCP progressive disclosure, so an agent and a
+human edit the same ``node.props`` by different routes.
+
+This reuses the Semantic Layer's schema machinery verbatim
+(``superset_core.semantic_layers.config.build_configuration_schema`` /
+``check_dependencies``) and the ``x-control`` / ``x-dynamic`` / ``x-dependsOn`` /
+``x-propertyOrder`` conventions.
+
+See ``WIDGET_FRAMEWORK.md`` for the broader proposal. This POC implements only
+the control-schema half of that contract; widget data is still fetched on the
+frontend via the v1 ``/api/v1/chart/data`` path. In the eventual design this
+package lives in ``superset-core``; it is kept in the app here to move quickly.
+"""
diff --git a/superset/widgets/api.py b/superset/widgets/api.py
new file mode 100644
index 00000000000..fe0b1879829
--- /dev/null
+++ b/superset/widgets/api.py
@@ -0,0 +1,291 @@
+# 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 __future__ import annotations
+
+import logging
+from typing import Any
+
+from flask import make_response, request, Response
+from flask_appbuilder.api import expose, protect, safe
+from flask_babel import gettext as _
+
+from superset.extensions import event_logger
+from superset.utils import json
+from superset.views.base_api import BaseSupersetApi, statsd_metrics
+from superset.widgets.registry import registry
+from superset.widgets.schema_tools import get_subtrees, SchemaPathError
+
+logger = logging.getLogger(__name__)
+
+
+class WidgetControlsRestApi(BaseSupersetApi):
+ """
+ Schema-driven controls for Dashboard V2 widgets (experimental).
+
+ Serves the backend-owned control JSON Schema that drives both the dashboard
+ Inspector's control panel and read-only MCP progressive disclosure. Widget
+ data is fetched separately via the v1 chart-data path on the frontend, so
+ there is no data endpoint here. The resource name ``widgets`` is
+ a placeholder (see ``WIDGET_FRAMEWORK.md``).
+ """
+
+ resource_name = "widgets"
+ allow_browser_login = True
+ # Read-only schema serving; reuse the existing Chart permission so no new
+ # permission is introduced.
+ class_permission_name = "Chart"
+ method_permission_name = {
+ "types": "read",
+ "control_schema": "read",
+ "validate": "read",
+ }
+ openapi_spec_tag = "Dashboard Controls (experimental)"
+
+ @expose("/types", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.types",
+ log_to_statsd=False,
+ )
+ def types(self) -> Response:
+ """List registered building-widget control sets.
+ ---
+ get:
+ summary: List widget types that have a schema-driven control panel
+ responses:
+ 200:
+ description: A list of widget types
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: array
+ items:
+ type: object
+ 401:
+ $ref: '#/components/responses/401'
+ """
+ result = [
+ {"id": cls.widget_type, "name": cls.name, "description": cls.description}
+ for cls in registry.values()
+ ]
+ return self.response(200, result=result)
+
+ @expose("/type/<widget_type>/control-schema", methods=("GET", "POST"))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.control_schema"
+ ),
+ log_to_statsd=False,
+ )
+ def control_schema(self, widget_type: str) -> Response:
+ """Return the control JSON Schema for a widget type (progressive
+ disclosure).
+
+ ``GET`` returns the base schema (no enrichment) — enough to discover a
+ type's fields and required props without a CSRF-bearing request. ``POST``
+ additionally accepts ``control_values``/``series`` to enrich x-dynamic
+ fields, and an optional ``paths`` array to return just those subtrees
+ (a ``{path: schema}`` map) instead of the whole schema.
+ ---
+ get:
+ summary: Get the base control schema for a widget type
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ responses:
+ 200:
+ description: Control JSON Schema
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ 404:
+ $ref: '#/components/responses/404'
+ post:
+ summary: Get the enriched control schema (or requested subtrees)
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ control_values:
+ type: object
+ series:
+ type: array
+ items:
+ type: string
+ paths:
+ type: array
+ items:
+ type: string
+ description: >-
+ When given, return a `{path: schema}` map of just these
+ drill-in subtrees instead of the whole schema.
+ responses:
+ 200:
+ description: Control JSON Schema, or a `{path: schema}` map
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ warning:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ widget = registry.get(widget_type)
+ if widget is None:
+ return self.response_404()
+ # GET carries no body (and no CSRF); tolerate a missing/non-JSON body so
+ # the base schema is returned for a plain read. A body that IS present
+ # must be a JSON object.
+ body = request.get_json(silent=True) or {}
+ if not isinstance(body, dict):
+ return self.response_400(message="Request body must be a JSON object.")
+ control_values = body.get("control_values")
+ series = body.get("series")
+ paths = body.get("paths")
+ if paths is not None and (
+ not isinstance(paths, list)
+ or not all(isinstance(path, str) for path in paths)
+ ):
+ return self.response_400(message="'paths' must be an array of strings.")
+
+ warning: str | None = None
+ try:
+ schema = widget.get_control_schema(control_values, series)
+ except Exception: # pylint: disable=broad-except
+ # Enrichment can fail (e.g. malformed dynamic values); showing the
+ # base form is better than a hard error — mirror the Semantic Layer.
+ warning = str(
+ _(
+ "Could not enrich the controls for this widget; showing the "
+ "default form. See the server logs for details."
+ )
+ )
+ logger.exception(
+ "Error enriching control schema for widget type %s", widget_type
+ )
+ schema = widget.get_control_schema(None, None)
+
+ # `paths` narrows the response to just those drill-in subtrees; without
+ # it the whole (enriched) schema is returned.
+ if paths:
+ try:
+ result: dict[str, Any] = get_subtrees(schema, paths)
+ except SchemaPathError as ex:
+ return self.response_400(message=str(ex))
+ else:
+ result = schema
+
+ payload: dict[str, Any] = {"result": result}
+ if warning:
+ payload["warning"] = warning
+ resp = make_response(json.dumps(payload, sort_keys=False), 200)
+ resp.headers["Content-Type"] = "application/json; charset=utf-8"
+ return resp
+
+ @expose("/type/<widget_type>/validate", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.validate",
+ log_to_statsd=False,
+ )
+ def validate(self, widget_type: str) -> Response:
+ """Validate control values against a widget type's model.
+
+ The commit-time gate: runs the widget's full model validation (including
+ cross-field rules declared on the control model) and returns any errors
+ as ``{"errors": [{"loc", "message"}]}`` (empty list when valid), so a
+ caller can reject a bad edit with an actionable message rather than
+ writing a silently-broken widget. Widget-agnostic — the rules live on
+ each widget's control model, not here.
+ ---
+ post:
+ summary: Validate control values for a widget type
+ parameters:
+ - in: path
+ name: widget_type
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ control_values:
+ type: object
+ responses:
+ 200:
+ description: Validation result
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ properties:
+ errors:
+ type: array
+ items:
+ type: object
+ 400:
+ $ref: '#/components/responses/400'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ widget = registry.get(widget_type)
+ if widget is None:
+ return self.response_404()
+ body = request.get_json(silent=True) or {}
+ if not isinstance(body, dict):
+ return self.response_400(message="Request body must be a JSON object.")
+ errors = widget.validate_control_values(body.get("control_values"))
+ return self.response(200, result={"errors": errors})
diff --git a/superset/widgets/builtin.py b/superset/widgets/builtin.py
new file mode 100644
index 00000000000..b18f633ebc4
--- /dev/null
+++ b/superset/widgets/builtin.py
@@ -0,0 +1,142 @@
+# 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.
+"""Built-in Dashboard V2 widgets. Importing this registers them.
+
+Built-ins use the exact same public contract an extension does — ``@widget`` +
+``Widget`` from ``superset_core.widgets`` — so there is no parallel registration
+path. This module is imported (for its decorator side-effects) by
+``inject_widget_implementations`` once the decorator is concrete.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+from pydantic import BaseModel
+from superset_core.widgets import Widget, widget
+
+from superset.widgets.controls import (
+ AgGridTableControls,
+ BalloonsControls,
+ EchartsControls,
+ MarkdownControls,
+ MetricTileControls,
+)
+
+
+@widget(
+ widget_type="markdown",
+ name="Markdown",
+ description="Rich text authored in Markdown.",
+)
+class Markdown(Widget):
+ controls_class = MarkdownControls
+
+
+@widget(
+ widget_type="echarts",
+ name="ECharts",
+ description="A chart from a raw ECharts option with $bind data markers.",
+)
+class Echarts(Widget):
+ controls_class = EchartsControls
+
+
+@widget(
+ widget_type="metric-tile",
+ name="Metric Tile",
+ description="A single live metric value rendered as a big number.",
+)
+class MetricTile(Widget):
+ controls_class = MetricTileControls
+
+
+@widget(
+ widget_type="ag-grid-table",
+ name="Table",
+ description="Query results rendered as an AG Grid table.",
+)
+class AgGridTable(Widget):
+ controls_class = AgGridTableControls
+
+
+@widget(
+ widget_type="balloons",
+ name="Balloons",
+ description=(
+ "Rising colored balloons, one per query row — colored and sized by the "
+ "query (Chart Framework v2 POC)."
+ ),
+)
+class Balloons(Widget):
+ """
+ Explicit/typed chart: renders one balloon per query row, colored and sized
+ per series. The per-series ``customize`` section is populated dynamically
+ once a grouping dimension is chosen and the frontend reports the distinct
+ series values (the SIP's ``x-dynamic`` pattern).
+ """
+
+ controls_class = BalloonsControls
+
+ # Default color per series index. Must match the frontend widget's palette
+ # so a series' color is stable before the author touches the customize
+ # control.
+ PALETTE = ["#e74c3c", "#3498db", "#2ecc71", "#f1c40f", "#9b59b6", "#1abc9c"]
+
+ # Upper bound on distinct series inlined into the schema, so a caller can't
+ # force unbounded schema construction / serialization by submitting a huge
+ # (or duplicate-heavy) series list.
+ MAX_SERIES = 100
+
+ @classmethod
+ def enrich_schema(
+ cls,
+ schema: dict[str, Any],
+ parsed: BaseModel | None,
+ series: list[str],
+ ) -> None:
+ # Nested models land in $defs; the x-dynamic field is Customization.series.
+ defs = schema.get("$defs", {})
+ series_prop = defs.get("Customization", {}).get("properties", {}).get("series")
+ style_def = defs.get("SeriesStyle")
+ if series_prop is None or style_def is None:
+ return
+ # Only populate once a grouping dimension is set and the frontend has
+ # reported the distinct series values from the query results.
+ dimensions = None
+ if parsed is not None:
+ data_binding = getattr(parsed, "data_binding", None)
+ dimensions = getattr(data_binding, "dimensions", None)
+ if not dimensions or not series:
+ return
+ # Dedupe (preserving order) and cap before doing per-series work, so an
+ # oversized/duplicate list can't blow up CPU, memory, or response size.
+ unique_series = list(dict.fromkeys(series))[: cls.MAX_SERIES]
+ # Replace the open-ended map with one inlined, pre-colored style per series.
+ series_prop.pop("additionalProperties", None)
+ properties: dict[str, Any] = {}
+ for index, value in enumerate(unique_series):
+ style = deepcopy(style_def)
+ style["properties"]["color"]["default"] = cls.PALETTE[
+ index % len(cls.PALETTE)
+ ]
+ # Title each group with the series value so the control panel labels
+ # it by series rather than by the shared model name ("SeriesStyle").
+ style["title"] = value
+ properties[value] = style
+ series_prop["properties"] = properties
diff --git a/superset/widgets/controls.py b/superset/widgets/controls.py
new file mode 100644
index 00000000000..c55e540de83
--- /dev/null
+++ b/superset/widgets/controls.py
@@ -0,0 +1,291 @@
+# 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.
+"""
+Pydantic control models for built-in Dashboard V2 widgets.
+
+Field aliases are the camelCase names the frontend reads from ``node.props``
+(e.g. ``datasetId``); Pydantic emits JSON Schema by alias by default, so the
+served schema's property names match what widgets and ``fetchQueryData`` use.
+
+Genuinely-required controls (``datasetId``, ``metrics``, ``dataBinding``) carry
+no default so they land in the schema's ``required`` array. That is what makes
+the MCP "minimum viable object" view meaningful: it surfaces exactly those
+mandatory leaves, leaving optional/styling fields behind a drill-in.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+
+class DataBinding(BaseModel):
+ """Query binding for a data-backed widget (mirrors the frontend
+ ``DataBindingSpec``). ``datasetId`` and ``metrics`` are mandatory; the rest
+ are optional."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int = Field(
+ alias="datasetId",
+ title="Dataset ID",
+ description="Numeric id of the dataset to query.",
+ )
+ metrics: list[Any] = Field(
+ title="Metrics",
+ description=(
+ "Metrics to fetch. Each entry is EITHER a string naming a saved "
+ 'metric on the dataset (e.g. "count"), OR an ad-hoc aggregate '
+ "object of the shape "
+ '{"expressionType": "SIMPLE", "column": {"column_name": "<col>"}, '
+ '"aggregate": "SUM"|"AVG"|"COUNT"|"COUNT_DISTINCT"|"MIN"|"MAX", '
+ '"label": "<optional display label>"}. Do not pass a raw SQL string '
+ 'like "SUM(sales)" — a plain string is looked up as a saved-metric '
+ "name, not evaluated as an expression."
+ ),
+ # Entries are heterogeneous (a name string or an ad-hoc object), so the
+ # generic panel edits the list as JSON rather than a typed array.
+ json_schema_extra={"x-control": "code", "x-language": "json"},
+ )
+ dimensions: list[str] = Field(
+ default_factory=list,
+ title="Dimensions",
+ description="Columns to group by (the categories / series).",
+ )
+ row_limit: int = Field(
+ default=1000,
+ ge=1,
+ alias="rowLimit",
+ title="Row limit",
+ description="Maximum number of rows to fetch.",
+ )
+
+
+class MetricTileControls(BaseModel):
+ """Controls for the ``metric-tile`` widget (a single "big number")."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description=(
+ "The query behind the tile. Resolve one metric; the first result "
+ "row's value is shown."
+ ),
+ )
+ decimals: int = Field(
+ default=0,
+ ge=0,
+ le=10,
+ title="Decimal places",
+ description="How many decimal places to show.",
+ )
+ prefix: str = Field(
+ default="",
+ title="Prefix",
+ description='Text shown before the value (e.g. "$").',
+ )
+ suffix: str = Field(
+ default="",
+ title="Suffix",
+ description='Text shown after the value (e.g. "%").',
+ )
+ label: str = Field(
+ default="",
+ title="Label",
+ description="Caption under the number. Defaults to the column name.",
+ )
+
+
+class AgGridTableControls(BaseModel):
+ """Controls for the ``ag-grid-table`` widget (query results as a table)."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description="The query whose rows fill the table.",
+ )
+ column_defs: list[dict[str, Any]] = Field(
+ default_factory=list,
+ alias="columnDefs",
+ title="Column definitions",
+ description=(
+ "Optional AG Grid column definitions. When empty, columns are "
+ "derived one-to-one from the query result columns."
+ ),
+ json_schema_extra={"x-control": "code", "x-language": "json"},
+ )
+
+
+class SeriesStyle(BaseModel):
+ """Per-series balloon styling, populated dynamically once the grouping
+ dimension is set and its distinct values are known."""
+
+ color: str = Field(
+ default="#e74c3c",
+ title="Color",
+ json_schema_extra={"x-control": "color"},
+ )
+ size_scale: float = Field(
+ default=1.0,
+ ge=0.25,
+ le=4.0,
+ alias="sizeScale",
+ title="Size scale (×)",
+ description=(
+ "Multiplier on the metric-derived balloon size for this series "
+ "(1 = as-is, 2 = twice as big)."
+ ),
+ )
+
+
+class Customization(BaseModel):
+ """Per-series customization for the balloons widget.
+
+ ``series`` is ``x-dynamic``: empty until the ``dataBinding`` grouping
+ dimension is set, then the backend fills in one styled entry per distinct
+ dimension value the frontend discovered from the query results.
+ """
+
+ series: dict[str, SeriesStyle] = Field(
+ default_factory=dict,
+ title="Per-series styling",
+ json_schema_extra={
+ "x-dynamic": True,
+ "x-dependsOn": ["dataBinding"],
+ # The valid keys of this map are the distinct values of the widget's
+ # color dimension — named by the ``colorDimension`` prop, or the last
+ # ``dataBinding`` dimension when that's unset. Declaring the source
+ # lets a generic client enumerate/validate the keys (rather than a
+ # consumer guessing values like "F" for "girl").
+ "x-key-source": {
+ "dimensionFromProp": "colorDimension",
+ "fallback": "lastDimension",
+ },
+ },
+ )
+
+
+class BalloonsControls(BaseModel):
+ """Controls for the ``balloons`` chart widget (Chart Framework v2 POC).
+
+ One balloon per query row, colored and sized per series (the distinct
+ values of the first grouping dimension). ``customize`` is optional and
+ populated on demand.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description=(
+ "The query behind the balloons. Group by one or more dimensions "
+ "(one balloon per resulting row); the metric sizes each balloon."
+ ),
+ )
+ color_dimension: str = Field(
+ default="",
+ alias="colorDimension",
+ title="Color dimension",
+ description=(
+ "Which grouping dimension colors the balloons — its distinct values "
+ "become the customizable series. The value MUST be one of "
+ "`dataBinding.dimensions`; if the dimension you want to color by "
+ "isn't grouped yet, add it to `dimensions` as well (it is not enough "
+ "to name it here). Leave empty to color by the last dimension. "
+ 'E.g. to color by gender: set this to "gender" AND include "gender" '
+ 'in dimensions (e.g. dimensions ["name", "gender"]).'
+ ),
+ )
+ customize: Customization = Field(
+ default_factory=Customization,
+ title="Customize",
+ description="Per-series color and size overrides.",
+ )
+
+ @model_validator(mode="after")
+ def _color_dimension_must_be_grouped(self) -> "BalloonsControls":
+ """``colorDimension`` colors balloons by a dimension's distinct values,
+ so that dimension must actually be grouped. Naming it here without
+ adding it to ``dataBinding.dimensions`` would silently color by nothing;
+ surface it as a validation error instead (the message tells the caller
+ exactly what to fix)."""
+ dimensions = self.data_binding.dimensions
+ if self.color_dimension and self.color_dimension not in dimensions:
+ raise ValueError(
+ f'colorDimension "{self.color_dimension}" must be one of the '
+ f"dataBinding dimensions {dimensions}; add it to `dimensions` "
+ f"as well."
+ )
+ return self
+
+
+class MarkdownControls(BaseModel):
+ """Controls for the ``markdown`` widget (rich text)."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ content: str = Field(
+ title="Text",
+ description="Markdown content rendered by the widget.",
+ json_schema_extra={"x-control": "markdown"},
+ )
+
+
+class EchartsControls(BaseModel):
+ """Controls for the ``echarts`` widget (a chart from a raw ECharts option).
+
+ Not modeled field-by-field: ``echartsOptions`` is a near-raw ECharts
+ ``option`` edited as JSON, with ``$bind`` markers splicing in the queried
+ data. The query itself is the standard ``dataBinding``.
+ """
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ data_binding: DataBinding = Field(
+ alias="dataBinding",
+ title="Data",
+ description="The query whose rows the ECharts option's $bind markers read.",
+ )
+ echarts_options: dict[str, Any] = Field(
+ default_factory=dict,
+ alias="echartsOptions",
+ title="ECharts option",
+ description=(
+ "A near-raw ECharts `option` object "
+ "(https://echarts.apache.org/en/option.html). Anywhere a literal "
+ "value would normally go, use a $bind marker to splice in the "
+ 'queried data or a theme token: {"$bind": {"source": "metric", '
+ '"alias": "<metric name>"}} or {"source": "dimension", "alias": '
+ '"<column name>"}} yields one array of that column\'s values (add '
+ '"single": true to unwrap a single-row value); {"$bind": {"source": '
+ '"records", "fields": {"name": "<col>", "value": "<col>"}}} yields '
+ "an array of {name, value} objects (e.g. for pie series data); and "
+ '{"$bind": {"source": "theme", "token": "<token>"}} yields a Superset '
+ "theme value. The $bind wrapper is required."
+ ),
+ json_schema_extra={
+ "x-control": "code",
+ "x-language": "json",
+ "x-spec-dialect": "echarts",
+ },
+ )
diff --git a/superset/widgets/registry.py b/superset/widgets/registry.py
new file mode 100644
index 00000000000..23381b7dc0f
--- /dev/null
+++ b/superset/widgets/registry.py
@@ -0,0 +1,32 @@
+# 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 registry of Dashboard V2 widgets, keyed by widget type.
+
+The public contract (``Widget`` base class + ``@widget`` decorator) lives in
+``superset_core.widgets`` so extensions can register widgets the same way
+built-ins do. This module holds only the host-side registry the concrete
+decorator writes into (see
+``superset.core.api.core_api_injection.inject_widget_implementations``),
+mirroring ``superset.semantic_layers.registry``.
+"""
+
+from __future__ import annotations
+
+from superset_core.widgets.base import Widget
+
+registry: dict[str, type[Widget]] = {}
diff --git a/superset/widgets/schema_tools.py b/superset/widgets/schema_tools.py
new file mode 100644
index 00000000000..1e677991cd4
--- /dev/null
+++ b/superset/widgets/schema_tools.py
@@ -0,0 +1,248 @@
+# 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.
+"""
+Progressive-disclosure helpers over a control JSON Schema.
+
+Shared by the REST API and the MCP tools so the two never diverge. Pure
+functions over the dict a ``Widget.get_control_schema`` returns (which
+may carry ``x-dynamic`` enrichment and ``$defs``).
+
+- ``prune_to_minimal_viable`` — the **minimum viable object** an agent can fill
+ in one shot: mandatory properties expanded down to their leaves (descending
+ into mandatory nested objects), every optional/nested branch replaced by an
+ opaque ``x-collapsed`` drill-in marker (carrying an ``x-path``). The root is
+ tagged ``x-disclosure: "minimal"`` so the consumer knows this is partial —
+ only mandatory nested leaves are present; optional branches must be fetched
+ with ``get_subtree``.
+- ``get_subtree`` — resolve a ``a/b`` path against the fully-enriched schema and
+ return that node re-rooted as a self-contained schema (all ``$ref`` inlined).
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+
+class SchemaPathError(KeyError):
+ """Raised when a drill-in ``path`` cannot be resolved against a schema."""
+
+
+def _defs(root: dict[str, Any]) -> dict[str, Any]:
+ return root.get("$defs", {}) or {}
+
+
+def _deref(root: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+ """Resolve a node's single ``$ref`` (optionally wrapped in a one-element
+ ``allOf``) against ``root['$defs']``, merging any sibling keys. Non-ref
+ nodes are returned unchanged. Not recursive — resolves one level."""
+ ref: str | None = None
+ extras: dict[str, Any] = {}
+ if "$ref" in node:
+ ref = node["$ref"]
+ extras = {k: v for k, v in node.items() if k != "$ref"}
+ elif (
+ isinstance(node.get("allOf"), list)
+ and len(node["allOf"]) == 1
+ and isinstance(node["allOf"][0], dict)
+ and "$ref" in node["allOf"][0]
+ ):
+ ref = node["allOf"][0]["$ref"]
+ extras = {k: v for k, v in node.items() if k != "allOf"}
+ if ref is None:
+ return node
+ target = deepcopy(_defs(root).get(ref.split("/")[-1], {}))
+ target.update(extras)
+ return target
+
+
+def _is_object(node: dict[str, Any]) -> bool:
+ return (
+ node.get("type") == "object"
+ or "properties" in node
+ or "additionalProperties" in node
+ )
+
+
+def _is_collapsible(root: dict[str, Any], node: dict[str, Any]) -> bool:
+ """Whether an optional branch is worth hiding behind a drill-in marker.
+
+ Only nested objects — and arrays of objects — are: their sub-fields can be
+ many or large, so inlining them would bloat the first fetch. Cheap leaves
+ (scalars, enums, arrays of scalars) are small and have nothing to drill
+ into, so they're inlined instead of costing an extra round trip.
+ """
+ if _is_object(node):
+ return True
+ if node.get("type") == "array":
+ items = node.get("items")
+ if isinstance(items, dict):
+ return _is_object(_deref(root, items))
+ return False
+
+
+def _inline(root: dict[str, Any], node: Any) -> Any:
+ """Recursively resolve every ``$ref`` against ``root['$defs']`` and drop
+ ``$defs``, returning a self-contained schema fragment."""
+ if isinstance(node, dict):
+ if "$ref" in node:
+ target = _defs(root).get(node["$ref"].split("/")[-1], {})
+ merged = {**target, **{k: v for k, v in node.items() if k != "$ref"}}
+ return _inline(root, merged)
+ return {k: _inline(root, v) for k, v in node.items() if k != "$defs"}
+ if isinstance(node, list):
+ return [_inline(root, item) for item in node]
+ return node
+
+
+def _contains_dynamic(node: Any) -> bool:
+ """Whether a schema fragment has any ``x-dynamic`` content anywhere inside.
+
+ ``x-dynamic`` marks a field whose shape/enum is derived from the current
+ query or control values (e.g. per-series styling keyed by the discovered
+ dimension values). A collapsed branch containing such a field expands
+ differently as the query changes; a branch without one is stable.
+ """
+ if isinstance(node, dict):
+ if node.get("x-dynamic"):
+ return True
+ return any(_contains_dynamic(value) for value in node.values())
+ if isinstance(node, list):
+ return any(_contains_dynamic(item) for item in node)
+ return False
+
+
+def _collapse(root: dict[str, Any], node: dict[str, Any], path: str) -> dict[str, Any]:
+ """An opaque drill-in marker for an optional/nested branch.
+
+ Keeps the field's own (terse) description/title as a human summary, but
+ never inlines its child ``properties`` — those are what a consumer must
+ fetch on drill-in via ``get_subtree``. Only the summary, type, and
+ ``x-path`` are exposed here, so the shape of the sub-fields stays hidden
+ until explicitly requested.
+
+ Marks the branch ``x-dynamic: true`` when its subtree's shape depends on the
+ current query/values (so the consumer knows to pass ``control_values`` /
+ ``series`` when expanding it, and to re-fetch it when the query changes).
+ A branch without that flag is static: expand it once and it won't change.
+ """
+ resolved = _deref(root, node)
+ description = (
+ node.get("description")
+ or resolved.get("description")
+ or resolved.get("title")
+ or ""
+ )
+ marker: dict[str, Any] = {
+ "type": resolved.get("type", "object"),
+ "x-collapsed": True,
+ "x-path": path,
+ }
+ if description:
+ marker["description"] = description
+ if _contains_dynamic(_inline(root, resolved)):
+ marker["x-dynamic"] = True
+ return marker
+
+
+def _prune_object(
+ root: dict[str, Any], node: dict[str, Any], prefix: str
+) -> dict[str, Any]:
+ node = _deref(root, node)
+ properties: dict[str, Any] = node.get("properties", {})
+ required = set(node.get("required", []))
+
+ out_properties: dict[str, Any] = {}
+ collapsed_any = False
+ for name, prop in properties.items():
+ path = f"{prefix}/{name}" if prefix else name
+ if name in required:
+ resolved = _deref(root, prop)
+ if _is_object(resolved):
+ # Mandatory nested object: descend to surface its own mandatory
+ # leaves (recursive minimum-viable).
+ out_properties[name] = _prune_object(root, resolved, path)
+ else:
+ # Mandatory leaf (scalar / array / enum): inline it whole.
+ out_properties[name] = _inline(root, prop)
+ else:
+ resolved = _deref(root, prop)
+ if _is_collapsible(root, resolved):
+ # Optional object / array-of-objects: hide behind a drill-in.
+ out_properties[name] = _collapse(root, prop, path)
+ collapsed_any = True
+ else:
+ # Optional cheap leaf: inline it — small, and nothing to drill.
+ out_properties[name] = _inline(root, prop)
+
+ result: dict[str, Any] = {"type": "object", "properties": out_properties}
+ for key in ("title", "description", "required"):
+ if key in node:
+ result[key] = node[key]
+ # Signal that some optional children were withheld (collapsed) from this
+ # object, so the consumer knows it is a partial view.
+ if collapsed_any:
+ result["x-partial"] = True
+ return result
+
+
+def prune_to_minimal_viable(schema: dict[str, Any]) -> dict[str, Any]:
+ """Return the minimum-viable-object view of a control schema (see module
+ docstring). The input is not mutated."""
+ result = _prune_object(schema, schema, prefix="")
+ result["x-disclosure"] = "minimal"
+ return result
+
+
+def _navigate(root: dict[str, Any], path: str) -> dict[str, Any]:
+ """Walk a ``a/b`` (or ``a.b``) path through ``properties`` /
+ ``additionalProperties``, dereferencing along the way."""
+ node: dict[str, Any] = root
+ segments = [seg for seg in path.replace(".", "/").split("/") if seg]
+ for segment in segments:
+ node = _deref(root, node)
+ properties = node.get("properties", {})
+ if segment in properties:
+ node = properties[segment]
+ elif isinstance(node.get("additionalProperties"), dict):
+ # A dynamic map (e.g. `series`): any key resolves to the item shape.
+ node = node["additionalProperties"]
+ else:
+ raise SchemaPathError(
+ f"Path segment {segment!r} not found in schema path {path!r}"
+ )
+ return node
+
+
+def get_subtree(schema: dict[str, Any], path: str) -> dict[str, Any]:
+ """Resolve ``path`` against ``schema`` and return that node as a
+ self-contained schema (``$ref`` inlined). Raises ``SchemaPathError`` for an
+ unresolvable path. The input is not mutated."""
+ if not path or not path.strip("/. "):
+ return _inline(schema, dict(schema))
+ node = _navigate(schema, path)
+ return _inline(schema, node)
+
+
+def get_subtrees(schema: dict[str, Any], paths: list[str]) -> dict[str, Any]:
+ """Resolve several drill-in ``paths`` in one pass, returning a
+ ``{path: subtree}`` map (each subtree self-contained, ``$ref`` inlined).
+
+ Lets a consumer expand multiple collapsed branches in a single round trip
+ instead of one call per branch. Raises ``SchemaPathError`` naming the first
+ unresolvable path. The input is not mutated."""
+ return {path: get_subtree(schema, path) for path in paths}
diff --git a/tests/unit_tests/mcp_service/widgets/__init__.py b/tests/unit_tests/mcp_service/widgets/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/widgets/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit_tests/mcp_service/widgets/tool/__init__.py b/tests/unit_tests/mcp_service/widgets/tool/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/widgets/tool/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py b/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py
new file mode 100644
index 00000000000..8e5fa57eb54
--- /dev/null
+++ b/tests/unit_tests/mcp_service/widgets/tool/test_widget_tools.py
@@ -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.
+
+"""Tests for the Dashboard V2 widget-control MCP tools."""
+
+from __future__ import annotations
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.widgets.tool.get_widget_control_schema import (
+ _get_widget_control_schema_impl,
+)
+from superset.mcp_service.widgets.tool.list_widget_types import (
+ _list_widget_types_impl,
+)
+from superset.utils import json
+
+
+def test_list_widget_types_impl() -> None:
+ ids = {b["id"] for b in _list_widget_types_impl()}
+ assert {"metric-tile", "ag-grid-table", "balloons"} <= ids
+
+
+def test_get_widget_control_schema_is_minimal() -> None:
+ result = _get_widget_control_schema_impl("balloons")
+ assert result["x-disclosure"] == "minimal"
+ assert result["properties"]["customize"]["x-collapsed"] is True
+ # Mandatory leaves are surfaced inline.
+ assert (
+ result["properties"]["dataBinding"]["properties"]["datasetId"]["type"]
+ == "integer"
+ )
+
+
+def test_get_widget_control_schema_unknown_type_error() -> None:
+ result = _get_widget_control_schema_impl("does-not-exist")
+ assert result["error"]["error_type"] == "invalid_widget_type"
+ assert "balloons" in result["valid_widget_types"]
+
+
+def test_get_widget_control_schema_drills_into_enriched_series() -> None:
+ result = _get_widget_control_schema_impl(
+ "balloons",
+ paths=["customize/series"],
+ control_values={
+ "dataBinding": {"datasetId": 1, "metrics": ["count"], "dimensions": ["g"]}
+ },
+ series=["boy", "girl"],
+ )
+ series_schema = result["subtrees"]["customize/series"]
+ assert set(series_schema["properties"]) == {"boy", "girl"}
+
+
+def test_get_widget_control_schema_expands_multiple_paths_at_once() -> None:
+ result = _get_widget_control_schema_impl(
+ "balloons", paths=["dataBinding", "customize"]
+ )
+ # Both requested branches come back in one call, keyed by path.
+ assert set(result["subtrees"]) == {"dataBinding", "customize"}
+ assert "datasetId" in result["subtrees"]["dataBinding"]["properties"]
+ assert "series" in result["subtrees"]["customize"]["properties"]
+
+
+def test_get_widget_control_schema_bad_path_error() -> None:
+ result = _get_widget_control_schema_impl("balloons", paths=["nope/nope"])
+ assert result["error"]["error_type"] == "invalid_path"
+
+
+@pytest.fixture(autouse=True)
+def mock_auth():
+ """Mock authentication for client-based tool tests."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+@pytest.mark.asyncio
+async def test_tools_registered_and_callable() -> None:
+ async with Client(mcp) as client:
+ names = {t.name for t in await client.list_tools()}
+ assert {"list_widget_types", "get_widget_control_schema"} <= names
+ # The separate field-schema tool was folded into get_widget_control_schema.
+ assert "get_widget_control_field_schema" not in names
+
+ result = await client.call_tool(
+ "get_widget_control_schema", {"widget_type": "metric-tile"}
+ )
+ data = json.loads(result.content[0].text)
+ assert data["x-disclosure"] == "minimal"
+ assert "dataBinding" in data["properties"]
+
+ # Passing paths returns the requested subtrees instead of the root.
+ drilled = await client.call_tool(
+ "get_widget_control_schema",
+ {"widget_type": "balloons", "paths": ["dataBinding"]},
+ )
+ drilled_data = json.loads(drilled.content[0].text)
+ assert "datasetId" in drilled_data["subtrees"]["dataBinding"]["properties"]
diff --git a/tests/unit_tests/widgets/__init__.py b/tests/unit_tests/widgets/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/widgets/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit_tests/widgets/conftest.py b/tests/unit_tests/widgets/conftest.py
new file mode 100644
index 00000000000..21c221bc17c
--- /dev/null
+++ b/tests/unit_tests/widgets/conftest.py
@@ -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.
+"""
+Make the ``@widget`` decorator concrete and register the built-in widgets
+before any test module in this directory is collected.
+
+These are lightweight unit tests that don't spin up the full app fixture, but
+``superset.widgets.builtin`` applies ``@widget`` at import time — which is a
+stub until ``inject_widget_implementations`` runs. conftest.py is imported
+before its sibling test modules, so injecting here guarantees the decorator is
+live (and the registry populated) first. The injection needs no app context.
+"""
+
+from superset.core.api.core_api_injection import inject_widget_implementations
+
+inject_widget_implementations()
diff --git a/tests/unit_tests/widgets/test_builtin.py b/tests/unit_tests/widgets/test_builtin.py
new file mode 100644
index 00000000000..8fc7dbd1333
--- /dev/null
+++ b/tests/unit_tests/widgets/test_builtin.py
@@ -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.
+from __future__ import annotations
+
+from superset.widgets.builtin import Balloons
+from superset.widgets.registry import registry
+
+
+def _series_properties(control_values, series):
+ widget = registry.get("balloons")
+ assert widget is not None
+ schema = widget.get_control_schema(control_values, series)
+ return schema["$defs"]["Customization"]["properties"]["series"]
+
+
+def test_series_stays_open_ended_without_dimension_or_series() -> None:
+ # No control values at all.
+ series = _series_properties(None, None)
+ assert "properties" not in series
+ # A grouping dimension but no discovered series values yet.
+ series = _series_properties(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ [],
+ )
+ assert "properties" not in series
+ # Discovered values but no grouping dimension chosen.
+ series = _series_properties(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}},
+ ["boy", "girl"],
+ )
+ assert "properties" not in series
+
+
+def test_series_populated_per_value_with_palette_colors() -> None:
+ series = _series_properties(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ ["boy", "girl", "other"],
+ )
+ assert set(series["properties"]) == {"boy", "girl", "other"}
+ # Open-ended map is replaced by concrete per-series styles.
+ assert "additionalProperties" not in series
+ # Each entry is titled by its series value and pre-colored from the palette.
+ assert series["properties"]["boy"]["title"] == "boy"
+ assert (
+ series["properties"]["boy"]["properties"]["color"]["default"]
+ == Balloons.PALETTE[0]
+ )
+ assert (
+ series["properties"]["girl"]["properties"]["color"]["default"]
+ == Balloons.PALETTE[1]
+ )
+
+
+def test_series_deduped_and_capped() -> None:
+ binding = {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ }
+ # Duplicates collapse (order preserved), so no repeated per-series work.
+ series = _series_properties(binding, ["boy", "girl", "boy", "girl"])
+ assert list(series["properties"]) == ["boy", "girl"]
+
+ # An oversized list is capped, bounding schema construction / response size.
+ many = [f"s{i}" for i in range(Balloons.MAX_SERIES + 50)]
+ series = _series_properties(binding, many)
+ assert len(series["properties"]) == Balloons.MAX_SERIES
diff --git a/tests/unit_tests/widgets/test_registry.py b/tests/unit_tests/widgets/test_registry.py
new file mode 100644
index 00000000000..66637e19098
--- /dev/null
+++ b/tests/unit_tests/widgets/test_registry.py
@@ -0,0 +1,128 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+from superset_core.widgets import Widget, widget
+
+from superset.widgets.controls import BalloonsControls
+from superset.widgets.registry import registry
+
+
+def _block(widget_type: str) -> type[Widget]:
+ widget_cls = registry.get(widget_type)
+ assert widget_cls is not None
+ return widget_cls
+
+
+def test_registry_lists_built_in_widget_types() -> None:
+ ids = {cls.widget_type for cls in registry.values()}
+ assert {"metric-tile", "ag-grid-table", "balloons"} <= ids
+
+
+def test_core_contract_is_importable() -> None:
+ # Extensions register widgets via exactly these two public symbols.
+ assert Widget is not None
+ assert callable(widget)
+
+
+def test_duplicate_widget_type_raises_naming_both() -> None:
+ @widget(widget_type="dup-test-widget", name="First")
+ class First(Widget):
+ controls_class = BalloonsControls
+
+ try:
+ with pytest.raises(ValueError, match="already registered"):
+
+ @widget(widget_type="dup-test-widget", name="Second")
+ class Second(Widget):
+ controls_class = BalloonsControls
+ finally:
+ registry.pop("dup-test-widget", None)
+
+
+def test_get_control_schema_base_shape() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ # Field order preserved (dataBinding before customize), $defs present.
+ assert list(schema["properties"]) == [
+ "dataBinding",
+ "colorDimension",
+ "customize",
+ ]
+ assert schema["required"] == ["dataBinding"]
+ assert {"DataBinding", "Customization", "SeriesStyle"} <= set(schema["$defs"])
+
+
+def test_get_control_schema_tolerates_invalid_values() -> None:
+ # Partial / malformed control values during editing must not raise; the base
+ # schema is returned instead.
+ schema = _block("balloons").get_control_schema(
+ {"dataBinding": "not-an-object"}, None
+ )
+ assert "properties" in schema
+
+
+def test_get_control_schema_accepts_camel_case_props() -> None:
+ # node.props uses camelCase aliases; validation must accept them.
+ schema = _block("metric-tile").get_control_schema(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}, "decimals": 2}, None
+ )
+ assert "dataBinding" in schema["properties"]
+
+
+def test_minimal_object_validates_against_model() -> None:
+ # datasetId + metrics are the only mandatory leaves; everything else is
+ # optional, so this minimal object is a valid instance.
+ BalloonsControls.model_validate(
+ {"dataBinding": {"datasetId": 1, "metrics": ["count"]}}
+ )
+
+
+def test_validate_control_values_passes_for_valid_props() -> None:
+ errors = _block("balloons").validate_control_values(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ },
+ "colorDimension": "gender",
+ }
+ )
+ assert errors == []
+
+
+def test_validate_control_values_flags_color_dimension_not_grouped() -> None:
+ # colorDimension names a dimension that isn't in dataBinding.dimensions —
+ # the declarative cross-field rule must surface an actionable error.
+ errors = _block("balloons").validate_control_values(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["name"],
+ },
+ "colorDimension": "gender",
+ }
+ )
+ assert errors
+ assert any("colorDimension" in error["message"] for error in errors)
+
+
+def test_validate_control_values_empty_when_no_values() -> None:
+ # Nothing to validate (required-field checks live elsewhere).
+ assert _block("balloons").validate_control_values(None) == []
diff --git a/tests/unit_tests/widgets/test_schema_tools.py b/tests/unit_tests/widgets/test_schema_tools.py
new file mode 100644
index 00000000000..b07da5daf8c
--- /dev/null
+++ b/tests/unit_tests/widgets/test_schema_tools.py
@@ -0,0 +1,125 @@
+# 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 __future__ import annotations
+
+import pytest
+
+from superset.widgets.registry import registry
+from superset.widgets.schema_tools import (
+ get_subtree,
+ get_subtrees,
+ prune_to_minimal_viable,
+ SchemaPathError,
+)
+
+
+def _balloons_schema(control_values=None, series=None):
+ widget = registry.get("balloons")
+ assert widget is not None
+ return widget.get_control_schema(control_values, series)
+
+
+def test_prune_surfaces_mandatory_leaves_and_collapses_the_rest() -> None:
+ minimal = prune_to_minimal_viable(_balloons_schema())
+
+ assert minimal["x-disclosure"] == "minimal"
+ # No $defs / $ref leak into the minimal view.
+ assert "$defs" not in minimal
+ # dataBinding is mandatory → expanded; its mandatory leaves are inline.
+ data_binding = minimal["properties"]["dataBinding"]
+ assert data_binding["properties"]["datasetId"]["type"] == "integer"
+ assert data_binding["properties"]["metrics"]["type"] == "array"
+ # Its optional but CHEAP leaves (array-of-strings, integer) are inlined too
+ # — small, and nothing to drill into, so no wasted round trip.
+ dimensions = data_binding["properties"]["dimensions"]
+ assert dimensions["type"] == "array"
+ assert "x-collapsed" not in dimensions
+ assert data_binding["properties"]["rowLimit"]["type"] == "integer"
+ # dataBinding has no collapsed (object) children → not partial.
+ assert "x-partial" not in data_binding
+ # colorDimension is an optional scalar at the root → inlined, not collapsed.
+ assert minimal["properties"]["colorDimension"]["type"] == "string"
+ assert "x-collapsed" not in minimal["properties"]["colorDimension"]
+ # customize is an optional OBJECT → collapsed at the root.
+ customize = minimal["properties"]["customize"]
+ assert customize["x-collapsed"] is True
+ assert customize["x-path"] == "customize"
+ # A terse description is KEPT — it's the breadcrumb telling a consumer what
+ # lives under this branch so it knows to drill in. But the child props
+ # themselves are withheld (that's what keeps the first fetch small); they're
+ # fetched on demand via get_subtree.
+ assert customize["description"] == "Per-series color and size overrides."
+ assert "properties" not in customize
+ # customize contains an x-dynamic branch (per-series styling keyed by the
+ # query's values), so the marker is flagged dynamic — the consumer knows it
+ # expands from the current query and must be re-fetched when the query
+ # changes.
+ assert customize["x-dynamic"] is True
+ # The root has a collapsed object child → flagged partial.
+ assert minimal["x-partial"] is True
+
+
+def test_collapsed_marker_is_static_when_no_dynamic_content() -> None:
+ widget = registry.get("ag-grid-table")
+ assert widget is not None
+ minimal = prune_to_minimal_viable(widget.get_control_schema(None, None))
+ # columnDefs is an optional array-of-objects → collapsed, but its shape does
+ # not depend on the query, so it carries no x-dynamic flag (it's static).
+ column_defs = minimal["properties"]["columnDefs"]
+ assert column_defs["x-collapsed"] is True
+ assert "x-dynamic" not in column_defs
+
+
+def test_get_subtree_inlines_a_branch() -> None:
+ subtree = get_subtree(_balloons_schema(), "dataBinding")
+ props = subtree["properties"]
+ assert {"datasetId", "metrics", "dimensions", "rowLimit"} <= set(props)
+ # Fully inlined — no dangling refs.
+ assert "$ref" not in str(subtree)
+
+
+def test_get_subtree_reaches_dynamic_series_after_enrichment() -> None:
+ enriched = _balloons_schema(
+ {
+ "dataBinding": {
+ "datasetId": 1,
+ "metrics": ["count"],
+ "dimensions": ["gender"],
+ }
+ },
+ ["boy", "girl"],
+ )
+ series = get_subtree(enriched, "customize/series")
+ assert set(series["properties"]) == {"boy", "girl"}
+ assert series["properties"]["boy"]["properties"]["color"]["x-control"] == "color"
+
+
+def test_get_subtree_raises_on_bad_path() -> None:
+ with pytest.raises(SchemaPathError):
+ get_subtree(_balloons_schema(), "customize/nope")
+
+
+def test_get_subtrees_expands_several_paths_in_one_call() -> None:
+ subtrees = get_subtrees(_balloons_schema(), ["dataBinding", "customize"])
+ assert set(subtrees) == {"dataBinding", "customize"}
+ assert "datasetId" in subtrees["dataBinding"]["properties"]
+ assert "series" in subtrees["customize"]["properties"]
+
+
+def test_get_subtrees_raises_on_any_bad_path() -> None:
+ with pytest.raises(SchemaPathError):
+ get_subtrees(_balloons_schema(), ["dataBinding", "customize/nope"])