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/11 17:24:29 UTC
(superset) branch dashboard-v2 updated: feat: Initial design of containers (#43065)
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 46be6a032d3 feat: Initial design of containers (#43065)
46be6a032d3 is described below
commit 46be6a032d371908c60a9e560823b78bff7725e1
Author: Michael S. Molina <70...@users.noreply.github.com>
AuthorDate: Tue Aug 11 14:24:21 2026 -0300
feat: Initial design of containers (#43065)
---
.../src/core/dashboard/BuildingBlockView.test.tsx | 6 +-
.../src/core/dashboard/BuildingBlockView.tsx | 241 ++++++---
.../src/core/dashboard/DashboardProvider.test.ts | 86 ++--
.../src/core/dashboard/DashboardProvider.ts | 58 ++-
.../CanvasBlock.test.tsx => RootGrid.test.tsx} | 44 +-
superset-frontend/src/core/dashboard/RootGrid.tsx | 549 +++++++++++++++++++++
.../src/core/dashboard/blockHeaderControl.tsx | 144 ++++++
.../src/core/dashboard/blockLabel.test.ts | 20 +-
superset-frontend/src/core/dashboard/blockLabel.ts | 47 +-
.../src/core/dashboard/blocks/AgGridTableBlock.tsx | 4 +-
.../src/core/dashboard/blocks/CanvasBlock.tsx | 302 ------------
.../src/core/dashboard/blocks/CarouselBlock.tsx | 246 +++++++++
.../src/core/dashboard/blocks/ChartBlock.tsx | 4 +-
.../src/core/dashboard/blocks/CollapsibleBlock.tsx | 97 ++++
.../src/core/dashboard/blocks/MarkdownBlock.tsx | 9 +-
.../src/core/dashboard/blocks/MetricTileBlock.tsx | 7 +-
.../src/core/dashboard/blocks/TabsBlock.test.tsx | 226 +++++++++
.../src/core/dashboard/blocks/TabsBlock.tsx | 246 +++++++++
.../src/core/dashboard/blocks/flowContent.tsx | 344 +++++++++++++
.../src/core/dashboard/gridPacking.ts | 77 ++-
.../src/core/dashboard/layoutStyle.ts | 2 +-
superset-frontend/src/core/dashboard/placement.ts | 113 ++++-
.../dashboard/registerBuiltInBuildingBlocks.ts | 70 ++-
.../src/core/dashboard/resolveBuildingBlockView.ts | 10 +-
.../pages/DashboardBuilderV2/EditorPanel.test.tsx | 23 +-
.../src/pages/DashboardBuilderV2/EditorPanel.tsx | 2 +-
.../pages/DashboardBuilderV2/Inspector.test.tsx | 2 +-
.../src/pages/DashboardBuilderV2/Palette.tsx | 59 ++-
.../src/pages/DashboardBuilderV2/index.test.tsx | 57 ++-
.../src/pages/DashboardBuilderV2/index.tsx | 102 +++-
30 files changed, 2628 insertions(+), 569 deletions(-)
diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
index eb01deb10bd..3686f18351b 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
+++ b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx
@@ -34,8 +34,8 @@ beforeEach(() => {
const withBlock = () => {
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 0, {
- type: 'markdown',
- props: { content: 'Quarterly notes' },
+ type: 'metric-tile',
+ props: { label: 'Quarterly notes' },
});
render(<BuildingBlockView nodeId={id} />);
return { rootId, id };
@@ -45,7 +45,7 @@ test('a block says which one it is', () => {
const { id } = withBlock();
// Named by the same call the Outline names its rows by, so a block is not
- // "Quarterly notes" in one place and "Markdown" in the other.
+ // "Quarterly notes" in one place and "Metric Tile" in the other.
expect(screen.getByTestId(`block-title-${id}`)).toHaveTextContent(
'Quarterly notes',
);
diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
index d0afe24fc45..db75dad6a7e 100644
--- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
+++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx
@@ -25,6 +25,8 @@ import { ErrorBoundary } from 'src/components';
import { provider, useDashboardRevision } from './store';
import { resolveBuildingBlockView } from './resolveBuildingBlockView';
import { blockLabel } from './blockLabel';
+import { blockHeaderControl } from './blockHeaderControl';
+import RootGrid from './RootGrid';
function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
const theme = useTheme();
@@ -66,16 +68,53 @@ const BlockHeader = styled.div`
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeightSM}px;
- /* Aligned with the inset every block's own content sits at, so a block's
- name reads as the head of the box beneath it rather than as something
- floating loose to its left. The right side stays tight: the remove
- button is a square target of its own and centres its icon, which is the
- inset it needs. */
- padding-left: ${theme.padding}px;
- padding-right: ${theme.sizeUnit}px;
+ flex: 0 0 auto;
`}
`;
+/**
+ * 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
+ * the header as one group.
+ *
+ * Grouped rather than each carrying its own `margin-left: auto`: the two
+ * controls have to land beside each other with nothing but the header's own
+ * gap between them, which a shared wrapper gives for free and two
+ * 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)
+ * leaves nothing on the other side to grow and do this instead.
+ */
+const HeaderTrailingControls = styled.span`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit}px;
+ flex: 0 0 auto;
+ margin-left: auto;
+ `}
+`;
+
+/**
+ * What a type's own extra header control (see `blockHeaderControl`) 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
+ * react-grid-layout drag.
+ *
+ * `data-block-header-control` is the other half of that second one —
+ * `RootGrid` names it in `draggableCancel`, and react-grid-layout matches
+ * the selector up the ancestors, so carrying it here covers the control
+ * inside.
+ */
+const HeaderControlSlot = styled.span`
+ display: flex;
+ flex: 0 0 auto;
+`;
+
/**
* What the remove control is wrapped in, and why it is wrapped at all.
*
@@ -86,7 +125,7 @@ const BlockHeader = styled.div`
* the bin must remove rather than select the block it is drawn on, and a
* pointer down on it must not start a react-grid-layout drag.
*
- * `data-block-remove` is the other half of that second one — `CanvasBlock`
+ * `data-block-remove` is the other half of that second one — `RootGrid`
* names it in `draggableCancel`, and react-grid-layout matches the selector up
* the ancestors, so carrying it here covers the button inside.
*/
@@ -102,13 +141,16 @@ interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
/**
* The single entry point for rendering a dashboard node. A node's `type` is
* resolved against `dashboard.buildingBlocks` views — built-in types
- * (canvas/markdown/echarts) and extension-contributed ones are registered
+ * (markdown/echarts/...) and extension-contributed ones are registered
* identically (see `registerBuiltInBuildingBlocks`), 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`.
*
- * A `canvas` parent (`CanvasBlock`) renders its children through
- * `react-grid-layout`, which positions/sizes each child by cloning it and
+ * The root is the one exception: it is not a Building Block (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` renders its children through `react-grid-layout`, which
+ * positions/sizes each child by cloning it and
* injecting `ref`/`style`/drag-and-resize handlers directly onto whatever
* element it renders — hence `forwardRef` and spreading `...rest` onto this
* component's own root div, rather than each block doing that itself. That's
@@ -135,14 +177,21 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
const node = provider.getNode(nodeId);
if (!node) return null;
- const resolved = resolveBuildingBlockView(node.type, nodeId);
const selected = provider.getSelection() === nodeId;
// The root is the dashboard itself rather than something on it: it has no
// name of its own to show, and removing it is refused by the provider, so
- // a header there would be a label saying "Canvas" over a button that only
+ // a header there would be a label saying "Grid" over a button that only
// 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 —
+ // 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)
+ );
// 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.
const headerHeight = theme.controlHeightSM;
@@ -192,6 +241,17 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
// is kept wherever it set one and `relative` only fills the gap
// when it did not.
position: rest.style?.position ?? 'relative',
+ // Sized the same way every other block already is — everything
+ // that is not the root gets its width/height from
+ // react-grid-layout, which clones the grid item and injects both
+ // directly (captured above through `...rest.style`). Nothing
+ // clones the root; 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 drop onto and a scrollbar
+ // that flickers in and out as that one block 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
// part of it.
//
@@ -207,26 +267,39 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
// 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
// onto whatever is behind it.
+ //
+ // None of this is true of the root. The root is not a block 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.
backgroundColor: isRoot ? undefined : theme.colorBgContainer,
- border: `1px solid ${theme.colorBorderSecondary}`,
- borderRadius: theme.borderRadiusLG,
+ border: isRoot
+ ? undefined
+ : `1px solid ${theme.colorBorderSecondary}`,
+ borderRadius: isRoot ? undefined : theme.borderRadiusLG,
// Nothing reaches past the corners this rounds — a block's content
- // is square and would otherwise fill them back in.
+ // is square and would otherwise fill them back in. Moot on the
+ // root, which rounds nothing.
overflow: isRoot ? undefined : 'hidden',
- // The dashboard's own gutter, and what makes it clickable.
- //
- // The root is drawn by a grid that fills its box edge to edge
- // (`containerPadding={[0, 0]}` in CanvasBlock), which left the
- // dashboard with no pixels of its own. The inset is what an author
- // aims at to select the dashboard rather than something on it — and
- // it is the same inset a block's content sits at, so the two read as
- // one scale rather than two. It carries no surface of its own: this
- // is what everything else is arranged *on*, not a card among them.
- padding: isRoot ? theme.padding : undefined,
+ // One inset for the whole card — the name and the content both
+ // sit inside it, rather than each drawing its own. `border-box`
+ // keeps it inside the pixel box `RootGrid`/`FlowItem` gave this
+ // element (a chart resizes to what's left after this is
+ // subtracted) instead of adding to it. The root gets none: it is
+ // 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
// space, so nothing on screen shifts when a selection moves.
- outline: selected ? `2px solid ${theme.colorPrimary}` : undefined,
- outlineOffset: selected ? -2 : undefined,
+ //
+ // 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
+ // from its neighbors instead reads as a frame around the entire
+ // dashboard when it is the root wearing it.
+ outline:
+ selected && !isRoot ? `2px solid ${theme.colorPrimary}` : undefined,
+ outlineOffset: selected && !isRoot ? -2 : undefined,
}}
>
{/* What this block is, and how to be rid of it.
@@ -237,7 +310,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
once instead of twice.
`data-block-remove` is what keeps a press on the button from
- starting a react-grid-layout drag; see CanvasBlock's
+ starting a react-grid-layout drag; see RootGrid's
`draggableCancel`. The propagation stops are the same idea for the
two gestures it sits inside: a click here removes rather than
selects, and a pointer down here grabs nothing.
@@ -249,49 +322,73 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
semantics and Properties carries the same Delete. */}
{chrome && (
<BlockHeader data-test={`block-header-${nodeId}`}>
- <Typography.Text
- ellipsis
- data-test={`block-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
- // it is drawn at the weight that job deserves.
- fontSize: theme.fontSize,
- fontWeight: theme.fontWeightStrong,
- color: theme.colorText,
- }}
- >
- {blockLabel(node.type, node.props)}
- </Typography.Text>
- <RemoveSlot
- data-block-remove
- onMouseDown={event => event.stopPropagation()}
- onPointerDown={event => event.stopPropagation()}
- onClick={event => event.stopPropagation()}
- >
- <ActionButton
- label={t('Remove block')}
- tooltip={t('Remove block')}
- placement="bottom"
- dataTest={`block-remove-${nodeId}`}
- onClick={() => provider.removeBuildingBlock(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
- // 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,
- // all of them lit red, would make a canvas read as a row of
- // things about to be deleted.
- icon={<Icons.DeleteOutlined iconSize="s" />}
- />
- </RemoveSlot>
+ {/* Skipped entirely for a type `blockLabel` 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) && (
+ <Typography.Text
+ ellipsis
+ data-test={`block-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
+ // it is drawn at the weight that job deserves.
+ fontSize: theme.fontSize,
+ fontWeight: theme.fontWeightStrong,
+ color: theme.colorText,
+ }}
+ >
+ {blockLabel(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
+ just a title and its content, not a title, a second bar,
+ and its content. See `blockHeaderControl`. */}
+ {blockHeaderControl(node.type, nodeId) && (
+ <HeaderControlSlot
+ data-block-header-control
+ onMouseDown={event => event.stopPropagation()}
+ onPointerDown={event => event.stopPropagation()}
+ onClick={event => event.stopPropagation()}
+ >
+ {blockHeaderControl(node.type, nodeId)}
+ </HeaderControlSlot>
+ )}
+ <RemoveSlot
+ data-block-remove
+ onMouseDown={event => event.stopPropagation()}
+ onPointerDown={event => event.stopPropagation()}
+ onClick={event => event.stopPropagation()}
+ >
+ <ActionButton
+ label={t('Remove block')}
+ tooltip={t('Remove block')}
+ placement="bottom"
+ dataTest={`block-remove-${nodeId}`}
+ onClick={() => provider.removeBuildingBlock(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
+ // 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,
+ // all of them lit red, would make a canvas read as a row of
+ // things about to be deleted.
+ icon={<Icons.DeleteOutlined iconSize="s" />}
+ />
+ </RemoveSlot>
+ </HeaderTrailingControls>
</BlockHeader>
)}
{/* The block's own box, which is the whole of this element's minus
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
index 07b887d2b5a..9c6f1ed1a36 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts
@@ -16,7 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import DashboardProvider from './DashboardProvider';
+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`),
+// and this suite tests `DashboardProvider` in isolation, without the
+// registration (`registerBuiltInBuildingBlocks`) that gives 'tabs'/'tab'
+// their own container status.
+const TEST_CONTAINER_TYPE = 'container';
+
+beforeAll(() => {
+ registerContainerType(TEST_CONTAINER_TYPE);
+});
beforeEach(() => {
DashboardProvider.getInstance().reset();
@@ -26,12 +38,12 @@ test('returns the singleton instance', () => {
expect(DashboardProvider.getInstance()).toBe(DashboardProvider.getInstance());
});
-test('starts with a blank root canvas', () => {
+test('starts with a blank root grid', () => {
const provider = DashboardProvider.getInstance();
expect(provider.getRoot()).toEqual({
id: 'root',
- type: 'canvas',
+ type: 'grid',
layout: { columns: 24, gap: 16 },
children: [],
});
@@ -60,11 +72,13 @@ test('addBuildingBlock clamps an out-of-range index', () => {
expect(provider.getRoot().children).toEqual([id]);
});
-test('addBuildingBlock gives canvas nodes an empty children array', () => {
+test('addBuildingBlock gives container nodes an empty children array', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const id = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+ const id = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
expect(provider.getNode(id)?.children).toEqual([]);
});
@@ -83,7 +97,7 @@ test('addBuildingBlock throws when the parent cannot hold children', () => {
const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(() => provider.addBuildingBlock(leafId, 0, { type: 'text' })).toThrow(
- /not a canvas/,
+ /not a container/,
);
});
@@ -98,15 +112,17 @@ test('removeBuildingBlock detaches the node from its parent', () => {
expect(provider.getRoot().children).toEqual([]);
});
-test('removeBuildingBlock removes an entire canvas subtree', () => {
+test('removeBuildingBlock removes an entire container subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
- const childId = provider.addBuildingBlock(canvasId, 0, { type: 'text' });
+ const containerId = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
+ const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
- provider.removeBuildingBlock(canvasId);
+ provider.removeBuildingBlock(containerId);
- expect(provider.getNode(canvasId)).toBeUndefined();
+ expect(provider.getNode(containerId)).toBeUndefined();
expect(provider.getNode(childId)).toBeUndefined();
});
@@ -131,13 +147,15 @@ test('removeBuildingBlock is a no-op for an unknown id', () => {
test('moveBuildingBlock relocates a node to a new parent at the given index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+ const containerId = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
const id = provider.addBuildingBlock(rootId, 1, { type: 'text' });
- provider.moveBuildingBlock(id, canvasId, 0);
+ provider.moveBuildingBlock(id, containerId, 0);
- expect(provider.getRoot().children).toEqual([canvasId]);
- expect(provider.getNode(canvasId)?.children).toEqual([id]);
+ expect(provider.getRoot().children).toEqual([containerId]);
+ expect(provider.getNode(containerId)?.children).toEqual([id]);
});
test('moveBuildingBlock keeps an explicit position when the parent is unchanged', () => {
@@ -159,14 +177,16 @@ test('moveBuildingBlock keeps an explicit position when the parent is unchanged'
});
});
-test('getParentId returns the canvas holding a node', () => {
+test('getParentId returns the container holding a node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
- const childId = provider.addBuildingBlock(canvasId, 0, { type: 'text' });
+ const containerId = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
+ const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
- expect(provider.getParentId(childId)).toBe(canvasId);
- expect(provider.getParentId(canvasId)).toBe(rootId);
+ expect(provider.getParentId(childId)).toBe(containerId);
+ expect(provider.getParentId(containerId)).toBe(rootId);
});
test('getParentId returns undefined for the root and for an unknown id', () => {
@@ -179,9 +199,11 @@ test('getParentId returns undefined for the root and for an unknown id', () => {
test('moveBuildingBlock throws when moving the root node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
+ const containerId = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
- expect(() => provider.moveBuildingBlock(rootId, canvasId, 0)).toThrow(
+ expect(() => provider.moveBuildingBlock(rootId, containerId, 0)).toThrow(
/Cannot move the root node/,
);
});
@@ -193,21 +215,23 @@ test('moveBuildingBlock throws when the target cannot hold children', () => {
const otherId = provider.addBuildingBlock(rootId, 1, { type: 'text' });
expect(() => provider.moveBuildingBlock(otherId, leafId, 0)).toThrow(
- /not a canvas/,
+ /not a container/,
);
});
test('moveBuildingBlock throws when moving a node into its own subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
- const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' });
- const childCanvasId = provider.addBuildingBlock(canvasId, 0, {
- type: 'canvas',
+ const containerId = provider.addBuildingBlock(rootId, 0, {
+ type: TEST_CONTAINER_TYPE,
+ });
+ const childContainerId = provider.addBuildingBlock(containerId, 0, {
+ type: TEST_CONTAINER_TYPE,
});
- expect(() => provider.moveBuildingBlock(canvasId, childCanvasId, 0)).toThrow(
- /into itself or one of its own descendants/,
- );
+ expect(() =>
+ provider.moveBuildingBlock(containerId, childContainerId, 0),
+ ).toThrow(/into itself or one of its own descendants/);
});
test("updateLayout merges into the node's existing layout", () => {
@@ -249,7 +273,7 @@ test('updateLayout displaces an explicitly placed sibling it now collides with',
// Growing `first` down into row 2 now overlaps `second`, which is also
// explicitly placed — this mirrors what an AI tool call (not a mouse
// drag) can do, since it goes through this method directly rather than
- // through CanvasBlock/react-grid-layout's own collision handling.
+ // through RootGrid/react-grid-layout's own collision handling.
provider.updateLayout(firstId, { rowSpan: 2 });
expect(provider.getNode(firstId)?.layout).toEqual({
@@ -401,7 +425,7 @@ test('removing the selected node clears the selection', () => {
test('removing a container clears a selection inside its subtree', () => {
const provider = DashboardProvider.getInstance();
const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
- type: 'canvas',
+ type: TEST_CONTAINER_TYPE,
});
const childId = provider.addBuildingBlock(sectionId, 0, { type: 'markdown' });
provider.setSelection(childId);
diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.ts b/superset-frontend/src/core/dashboard/DashboardProvider.ts
index 4bd44eb0384..adc2b6d3c59 100644
--- a/superset-frontend/src/core/dashboard/DashboardProvider.ts
+++ b/superset-frontend/src/core/dashboard/DashboardProvider.ts
@@ -32,26 +32,44 @@ type StoredNode = Omit<DashboardNode, 'id'>;
const ROOT_ID = 'root';
/**
- * The one node type that holds other nodes.
- *
- * Named here because two things need to agree on it and neither should learn
- * it by string comparison of its own: this provider, deciding whether a new
- * node gets a `children` array at all, and the palette, deciding whether a
- * block an author places is a container or something to put in one.
+ * The root's own type — the one container every dashboard always has,
+ * whether or not anything has registered itself as one. Named here because
+ * two things need to agree on it and neither should learn it by string
+ * 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.
*/
-export const CONTAINER_TYPE = 'canvas';
+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
+ * 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
+ * question the provider itself needs: whether a freshly added node of this
+ * type gets a `children` array to hold them in.
+ */
+const registeredContainerTypes = new Set<string>();
+
+/** Marks `type` as a container, alongside the root's own `grid`. */
+export function registerContainerType(type: string): void {
+ registeredContainerTypes.add(type);
+}
/** Whether placing this type produces something other nodes can go inside. */
export const isContainerType = (type: string): boolean =>
- type === CONTAINER_TYPE;
+ type === GRID_TYPE || registeredContainerTypes.has(type);
function createBlankNodes(): Record<string, StoredNode> {
return {
[ROOT_ID]: {
- type: 'canvas',
+ type: GRID_TYPE,
// No total height is set here, and none is needed — the root grid's
// rows are created on demand (see resolveContainerGridStyle), so the
- // canvas is always exactly as tall as its content.
+ // grid is always exactly as tall as its content.
layout: { columns: DEFAULT_COLUMNS, gap: 16 },
children: [],
},
@@ -157,8 +175,8 @@ class DashboardProvider {
public getNode = (id: string): DashboardNode | undefined => this.toNode(id);
/**
- * The canvas a node sits in, or `undefined` for the root and for a node
- * that is not in the tree.
+ * 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
* every caller that moves a node already has to know which parent it is in
@@ -193,7 +211,7 @@ class DashboardProvider {
* collide with one another (see {@link resolveExplicitCollisions}) and
* folds the result into `nodes`. `addBuildingBlock`/`updateLayout` are the
* two ways an extension's AI tools place a node without going through
- * `CanvasBlock`'s interactive drag/resize at all — this gives that
+ * `RootGrid`'s interactive drag/resize at all — this gives that
* programmatic path the same "nothing ends up stuck overlapping"
* guarantee a mouse-driven resize gets for free from `react-grid-layout`,
* rather than leaving it to whatever the renderer happens to paper over
@@ -231,13 +249,19 @@ class DashboardProvider {
index: number,
spec: BuildingBlockSpec,
): 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`,
+ );
+ }
+
const parent = this.nodes[parentId];
if (!parent) {
throw new Error(`[dashboard] Unknown parent node "${parentId}"`);
}
if (!parent.children) {
throw new Error(
- `[dashboard] Node "${parentId}" cannot hold children (not a canvas)`,
+ `[dashboard] Node "${parentId}" cannot hold children (not a container)`,
);
}
@@ -303,7 +327,7 @@ class DashboardProvider {
}
if (!this.nodes[newParentId]?.children) {
throw new Error(
- `[dashboard] Node "${newParentId}" cannot hold children (not a canvas)`,
+ `[dashboard] Node "${newParentId}" cannot hold children (not a container)`,
);
}
if (this.isNodeOrDescendant(id, newParentId)) {
@@ -334,7 +358,7 @@ class DashboardProvider {
// column count) was only ever meaningful in the *old* parent's grid —
// carrying it over verbatim into the new one is how a moved node ends
// up silently overlapping or overflowing its new siblings. Interactive
- // drag-based reparenting (see `CanvasBlock`'s `handleDragStop`) already
+ // drag-based reparenting (see `RootGrid`'s `handleDragStop`) already
// resets exactly these two things on drop; this is that same reset,
// applied here so the programmatic path gives the same guarantee.
//
@@ -381,7 +405,7 @@ class DashboardProvider {
/**
* Merges a `layout` update into each of several nodes at once, in a single
- * commit. A drag or resize that displaces siblings (see `CanvasBlock`)
+ * commit. A drag or resize that displaces siblings (see `RootGrid`)
* resolves *all* of their new positions together — committing them one
* {@link updateLayout} call at a time would tick the revision counter, and
* so re-render every subscriber, once per displaced sibling instead of
diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
similarity index 80%
rename from superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
rename to superset-frontend/src/core/dashboard/RootGrid.test.tsx
index d0ec4b723ef..5889e4aa24b 100644
--- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx
+++ b/superset-frontend/src/core/dashboard/RootGrid.test.tsx
@@ -17,16 +17,15 @@
* under the License.
*/
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
-import DashboardProvider from '../DashboardProvider';
-import CanvasBlock from './CanvasBlock';
+import DashboardProvider from './DashboardProvider';
+import RootGrid from './RootGrid';
/**
* What a gesture on the grid commits.
*
- * `react-grid-layout` is mocked down to the props CanvasBlock feeds it.
- * Everything about how it draws is its own business and covered by its own
- * tests; what matters here is that CanvasBlock always compacts and never
- * allows overlap.
+ * `react-grid-layout` is mocked down to the props RootGrid feeds it. Everything
+ * about how it draws is its own business and covered by its own tests; what
+ * matters here is that RootGrid always compacts and never allows overlap.
*/
jest.mock('react-grid-layout/legacy', () => ({
__esModule: true,
@@ -67,7 +66,7 @@ const mount = () => {
const rootId = provider.getRoot().id;
const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
- render(<CanvasBlock nodeId={rootId} />);
+ render(<RootGrid nodeId={rootId} />);
return { rootId, first, second };
};
@@ -79,17 +78,17 @@ test('a grid compacts its children and does not let them overlap', () => {
expect(grid).toHaveAttribute('data-allow-overlap', 'false');
});
-test('the corner a block removes itself from is not also a resize handle', () => {
+test('a block resizes from all four corners', () => {
mount();
- // react-grid-layout appends its handles after a block's own content, so the
- // north-east one landed on the remove control and took every click aimed at
- // it -- `elementFromPoint` at that button's centre returned the handle. A
- // handle nobody can grab is worse than no handle: the corner still looks
- // resizable, and answers a drag that starts a pixel to either side.
+ // Used to exclude the north-east corner, which sat under the remove
+ // control -- react-grid-layout appends its handles after a block's own
+ // content, so a handle there took every click aimed at the button beneath
+ // it. The single card-wide inset (see `BuildingBlockView`) moved the
+ // button far enough from the true corner that both now fit.
expect(screen.getByTestId('rgl')).toHaveAttribute(
'data-resize-handles',
- 'se,sw,nw',
+ 'se,sw,nw,ne',
);
});
@@ -108,7 +107,7 @@ const paletteTransfer = (type: string) => {
test('dropping a palette block on a container places it there', () => {
const { rootId } = mount();
- fireEvent.drop(screen.getByTestId('canvas-container'), {
+ fireEvent.drop(screen.getByTestId('grid-container'), {
dataTransfer: paletteTransfer('markdown'),
});
@@ -121,7 +120,7 @@ test('a drop carrying something else is not read as a block', () => {
const { rootId } = mount();
const before = provider.getNode(rootId)?.children?.length;
- fireEvent.drop(screen.getByTestId('canvas-container'), {
+ fireEvent.drop(screen.getByTestId('grid-container'), {
dataTransfer: {
types: ['text/plain'],
getData: () => '',
@@ -176,6 +175,19 @@ test('the grid is told not to start a drag from the remove control', () => {
).not.toBeNull();
});
+test('the grid is also told not to start a drag from a nested block resize handle', () => {
+ mount();
+
+ // The same guard as the remove control, for the same reason — a `tabs`
+ // block sitting on this grid renders its own resize handle (see
+ // `TabsBlock`'s `FlowItem`) for a block flowed into one of its panes, and
+ // without this, resizing that block also drags the `tabs` item holding it.
+ const cancel = screen
+ .getByTestId('rgl')
+ .getAttribute('data-draggable-cancel');
+ expect(cancel).toContain('[data-block-resize]');
+});
+
test('clicking the remove control removes rather than selects', () => {
const { first, second } = mount();
diff --git a/superset-frontend/src/core/dashboard/RootGrid.tsx b/superset-frontend/src/core/dashboard/RootGrid.tsx
new file mode 100644
index 00000000000..f616626dd83
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/RootGrid.tsx
@@ -0,0 +1,549 @@
+/**
+ * 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 { useCallback } from 'react';
+import type { DragEvent as ReactDragEvent } from 'react';
+// The v1-compatible flat-props API (this component's own model: a plain
+// `cols`/`rowHeight`/`margin` grid, no responsive breakpoints — see the
+// design doc's "Explicitly deferred" section) lives at this subpath in v2;
+// the package's main entry exports only the newer composable API.
+import GridLayoutLegacy, { WidthProvider } from 'react-grid-layout/legacy';
+import type { Layout, LayoutItem } from 'react-grid-layout/legacy';
+import 'react-grid-layout/css/styles.css';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import { provider, useDashboardRevision } from './store';
+import { resolveGridMetrics } from './layoutStyle';
+import { availableDropSpan, packChildLayout } from './gridPacking';
+import {
+ FALLBACK_ROW_SPAN,
+ PALETTE_MIME,
+ placeBlock,
+ placeBlockAt,
+} from './placement';
+import BuildingBlockView from './BuildingBlockView';
+
+type LayoutProps = dashboardApi.LayoutProps;
+
+const ResizableGridLayout = WidthProvider(GridLayoutLegacy);
+
+/** `react-grid-layout`'s own item id for the live drop preview — see `handleDropDragOver`. Distinct from `DashboardProvider`'s own `node_<n>` ids by construction, so it can never collide with a real node while a drag is in progress. */
+const DROPPING_ITEM_ID = '__dropping-block__';
+
+/**
+ * Where a dragged or (via `isDroppable`, below) freshly dropped block is
+ * about to land — the one on-screen answer to "where, exactly" for a
+ * gesture that otherwise only shows the cursor.
+ *
+ * `react-grid-layout/css/styles.css` (imported above) styles its
+ * `.react-grid-placeholder` red at 0.2 opacity by default — a rectangle this
+ * restyles in the app's own primary colour instead, rather than editing the
+ * package's own stylesheet (not this codebase's to change, and gone on the
+ * next install). A descendant selector under this element's own generated
+ * class outweighs the plain two-class rule it is overriding, so this wins
+ * without needing `!important`.
+ *
+ * `react-grid-layout` renders the identical placeholder for a *resize* too,
+ * carrying its own extra `placeholder-resizing` class — resizing an
+ * existing block already shows that block's own edges moving live under the
+ * pointer, so a second box drawn on top of it would only repeat what is
+ * already on screen. The extra class is what tells the two apart; the base
+ * rule is left for the drag/drop case this was actually added for.
+ */
+const GridSurface = styled.div`
+ ${({ theme }) => css`
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+
+ .react-grid-item.react-grid-placeholder {
+ background-color: ${theme.colorPrimaryBg};
+ border: 2px dashed ${theme.colorPrimary};
+ border-radius: ${theme.borderRadiusLG}px;
+ opacity: 1;
+
+ &.placeholder-resizing {
+ background: transparent;
+ border: none;
+ opacity: 0;
+ }
+ }
+ `}
+`;
+
+// DEBUG INSTRUMENTATION — kept in place on purpose while the drag/resize
+// interaction is still being worked out; remove only when explicitly asked
+// to. Logs which node id/type a gesture actually started on (so it's
+// obvious, e.g., when a click lands on a nested container instead of the
+// chart the user meant to grab) and what RootGrid ends up committing at the end
+// of it.
+const DASHBOARD_DRAG_DEBUG = true;
+function debugLog(event: string, detail: Record<string, unknown>) {
+ if (!DASHBOARD_DRAG_DEBUG) return;
+ // eslint-disable-next-line no-console
+ console.debug(`[dashboard-drag] ${event}`, detail);
+}
+function describeNode(id: string | undefined) {
+ if (!id) return undefined;
+ const node = provider.getNode(id);
+ return { id, type: node?.type };
+}
+
+/**
+ * Finds the deepest dashboard container actually under a screen point,
+ * ignoring `excludeEl`'s own subtree — the block 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
+ * DOM they render a drop target from), so this is the one piece of
+ * cross-container awareness a drag needs: which container the pointer is
+ * over right now, at any nesting depth, without any container needing to
+ * know about any other container's existence.
+ *
+ * `excludeEl`'s `pointer-events` is toggled off for the single synchronous
+ * `elementFromPoint` call so the hit-test sees through the dragged element
+ * to whatever is actually underneath it on screen (otherwise the dragged
+ * element — positioned directly under the cursor by definition — would
+ * always be its own top hit).
+ */
+function findContainerIdAt(
+ clientX: number,
+ clientY: number,
+ excludeEl: HTMLElement,
+): string | null {
+ const previousPointerEvents = excludeEl.style.pointerEvents;
+ excludeEl.style.pointerEvents = 'none';
+ const hit = document.elementFromPoint(clientX, clientY);
+ excludeEl.style.pointerEvents = previousPointerEvents;
+ return (
+ hit?.closest<HTMLElement>('[data-container-id]')?.dataset.containerId ??
+ null
+ );
+}
+
+/**
+ * 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/`
+ * 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`
+ * resolves the root's renderer to this component directly, rather than
+ * through the `dashboard.buildingBlocks` registry every real building block
+ * goes through — nothing places a `RootGrid`, and nothing ever will, the same
+ * way nothing places the dashboard itself.
+ *
+ * Backed by `react-grid-layout`. All position/size math — including
+ * collision handling — is `react-grid-layout`'s: resizing a block never
+ * shrinks a sibling, only displaces it to the next open slot (clamped to
+ * `minW`/`minH: 1`), which leaves every block's own authored (hand- or
+ * AI-set) span untouched. This component's job is translating between the
+ * stored `col`/`row`/`colSpan`/`rowSpan` schema (which allows a child to
+ * omit its position entirely, to be auto-placed) and `react-grid-layout`'s
+ * own `{x, y, w, h}` — see `packChildLayout` for the auto-placement piece RGL
+ * has no concept of — and committing back to the store only once a gesture
+ * ends (`onDragStop`/`onResizeStop`), never on every intermediate frame.
+ *
+ * Dragging a block 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 `react-grid-layout` (scoped to one grid instance) handles on
+ * its own.
+ */
+export default function RootGrid({ nodeId }: { nodeId: string }) {
+ useDashboardRevision();
+ const theme = useTheme();
+ const node = provider.getNode(nodeId);
+
+ const commitLayout = useCallback(
+ (rglLayout: Layout) => {
+ const updates: Record<string, Partial<LayoutProps>> = {};
+ rglLayout.forEach(item => {
+ updates[item.i] = {
+ col: item.x + 1,
+ row: item.y + 1,
+ colSpan: item.w,
+ rowSpan: item.h,
+ };
+ });
+ debugLog('commitLayout', { containerId: nodeId, updates });
+ provider.updateLayouts(updates);
+ },
+ [nodeId],
+ );
+
+ const handleDragStart = useCallback(
+ (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+ debugLog('dragStart', {
+ containerId: nodeId,
+ dragged: describeNode(newItem?.i),
+ });
+ },
+ [nodeId],
+ );
+
+ const handleResizeStart = useCallback(
+ (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+ debugLog('resizeStart', {
+ containerId: nodeId,
+ resized: describeNode(newItem?.i),
+ });
+ },
+ [nodeId],
+ );
+
+ const handleResizeStop = useCallback(
+ (rglLayout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
+ debugLog('resizeStop', {
+ containerId: nodeId,
+ resized: describeNode(newItem?.i),
+ });
+ commitLayout(rglLayout);
+ },
+ [nodeId, commitLayout],
+ );
+
+ const handleDragStop = useCallback(
+ (
+ rglLayout: Layout,
+ _oldItem: unknown,
+ newItem: { i: string } | null,
+ _placeholder: unknown,
+ event: Event,
+ element: HTMLElement | null,
+ ) => {
+ const { clientX, clientY } = event as MouseEvent;
+ const targetContainerId =
+ newItem && element && clientX != null && clientY != null
+ ? findContainerIdAt(clientX, clientY, element)
+ : null;
+
+ debugLog('dragStop', {
+ containerId: nodeId,
+ dragged: describeNode(newItem?.i),
+ targetContainerId,
+ reparenting: !!targetContainerId && targetContainerId !== nodeId,
+ });
+
+ if (targetContainerId && targetContainerId !== nodeId && newItem) {
+ try {
+ // moveBuildingBlock 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(newItem.i, targetContainerId, destIndex);
+ return;
+ } catch {
+ // Dropped onto itself or one of its own descendants — not a valid
+ // reparent target. Fall through and keep it in this container.
+ }
+ }
+
+ commitLayout(rglLayout);
+ },
+ [nodeId, commitLayout],
+ );
+
+ if (!node) return null;
+
+ const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme);
+ const children = node.children ?? [];
+
+ const packed = packChildLayout(children, columns, provider.getNode);
+ const layout: Layout = children.map(id => ({
+ i: id,
+ minW: 1,
+ minH: 1,
+ ...packed[id],
+ }));
+
+ /**
+ * How wide a block being dragged in from the palette should preview at,
+ * for wherever the pointer currently is — react-grid-layout's own
+ * `isDroppable` calls this on every dragover and merges the `w`/`h` it
+ * returns into the live placeholder, so this is the one place that decides
+ * "how much room is actually here" rather than the block always claiming
+ * a fixed span regardless of what it is being dropped next to.
+ *
+ * Not a palette drag at all (a file from the desktop, dragged text, …) is
+ * `false`: nothing else on the grid should react to it, and returning
+ * anything else would show a preview for a drop this grid never accepts
+ * in the first place.
+ *
+ * Hovering over a *nested* container (a `tabs`/`collapsible`/`carousel`
+ * block sitting on this grid, or anything a third party contributes) is
+ * `false` for the identical reason: 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 belongs, not beside or on top of the container's own card on
+ * *this* grid. Without this, both react to the same hover — this preview
+ * showing a push-down where the container sits, the container's own
+ * unaware that anything is above it — and only one of them ever runs its
+ * cleanup on drop (`FlowContent`'s own `onDrop` stops the event there),
+ * leaving this one's placeholder on screen with nothing left to clear it.
+ *
+ * The width and height themselves are `availableDropSpan`'s own call (see
+ * `gridPacking.ts`) — this only has to translate the cursor's pixel
+ * position into the grid cell underneath it, the same conversion
+ * `react-grid-layout` itself does internally to place every other item. A
+ * gap between two existing blocks that is already tall enough on its own
+ * is exactly where that matters: without shrinking to fit it, the preview
+ * would keep asking for its own full, unshrunk height regardless of how
+ * little of it the gap actually has room for, and collide its way into
+ * shoving the block below down to clear space that was never short to
+ * begin with.
+ *
+ * `dragOffsetX`/`dragOffsetY` correct for something react-grid-layout does
+ * on its own that reads wrong for this gesture: left uncorrected, it
+ * centers the preview *on* the cursor, so a block six rows tall starts
+ * three rows above wherever the pointer actually is. Dropped "just below"
+ * a row of blocks, that is enough to land back on top of them instead —
+ * exactly what this offsets away, by feeding back half of whatever pixel
+ * size the preview is about to be (`react-grid-layout`'s own
+ * `calcGridItemWHPx` math, reproduced here since nothing shorter reaches
+ * it) so the centering it does and the anchoring this undoes cancel out,
+ * and the preview's top-left corner ends up *at* the cursor instead.
+ */
+ const handleDropDragOver = (
+ event: ReactDragEvent<HTMLDivElement>,
+ ):
+ | {
+ w?: number;
+ h?: number;
+ dragOffsetX?: number;
+ dragOffsetY?: number;
+ }
+ | false => {
+ if (!event.dataTransfer.types.includes(PALETTE_MIME)) {
+ return false;
+ }
+
+ const hoveredContainerId = (
+ event.target as HTMLElement
+ ).closest<HTMLElement>('[data-container-id]')?.dataset.containerId;
+ if (hoveredContainerId && hoveredContainerId !== nodeId) {
+ return false;
+ }
+
+ const gridRect = event.currentTarget.getBoundingClientRect();
+ const colWidth = (gridRect.width - gap * (columns - 1)) / columns;
+ const colStride = colWidth + gap;
+ const rowStride = rowUnitPx + gap;
+ const cursorCol = Math.min(
+ columns - 1,
+ Math.max(0, Math.floor((event.clientX - gridRect.left) / colStride)),
+ );
+ const cursorRow = Math.max(
+ 0,
+ Math.floor((event.clientY - gridRect.top) / rowStride),
+ );
+
+ const { w, h } = availableDropSpan(
+ packed,
+ columns,
+ cursorCol,
+ cursorRow,
+ FALLBACK_ROW_SPAN,
+ );
+ const pixelWidth = Math.round(colWidth * w + Math.max(0, w - 1) * gap);
+ const pixelHeight = Math.round(rowUnitPx * h + Math.max(0, h - 1) * gap);
+
+ return {
+ w,
+ h,
+ dragOffsetX: pixelWidth / 2,
+ dragOffsetY: pixelHeight / 2,
+ };
+ };
+
+ /**
+ * Where a palette drag actually lands, once released — the counterpart to
+ * `GridSurface`'s own plain `onDrop` (below, for a drop that missed the
+ * grid entirely, into the leftover space past its last row): this one
+ * fires when react-grid-layout's own `isDroppable` resolved a real x/y/w/h
+ * for the drop, live, during the drag itself (see `handleDropDragOver`),
+ * so there is no position left to compute here — only a type to read off
+ * the browser's own drag data, and where among the existing children this
+ * new one's own row/col actually falls (see `placeBlockAt`'s own doc
+ * comment for why that has to be figured out here rather than left to
+ * default to "at the end").
+ */
+ const handleExternalDrop = (
+ _rglLayout: Layout,
+ item: LayoutItem | undefined,
+ event: Event,
+ ) => {
+ const type = (event as DragEvent).dataTransfer?.getData(PALETTE_MIME);
+ if (!type || !item) return;
+
+ const readingOrderIndex = children.findIndex(id => {
+ const rect = packed[id];
+ return rect.y > item.y || (rect.y === item.y && rect.x >= item.x);
+ });
+ const index =
+ readingOrderIndex === -1 ? children.length : readingOrderIndex;
+
+ debugLog('dropOnGrid', { containerId: nodeId, type, item, index });
+ placeBlockAt(nodeId, type, index, {
+ col: item.x + 1,
+ row: item.y + 1,
+ colSpan: item.w,
+ rowSpan: item.h,
+ });
+ };
+
+ return (
+ <GridSurface
+ data-container-id={nodeId}
+ data-test="grid-container"
+ // The grid itself (`ResizableGridLayout`, below) now answers a drop
+ // anywhere over its own rendered rows via `isDroppable` — this pair
+ // only still fires for the leftover space past its last row (the
+ // grid is exactly as tall as its content, and this element is not),
+ // where appending full-width at the end is still the right answer,
+ // the same one it always was.
+ //
+ // Every container is a drop target, not just the root: a nested
+ // section is exactly where an author means to put something when they
+ // drag it there, and the stop (in `onDrop`, below) is what makes the
+ // innermost container under the pointer the one that takes it rather
+ // than every ancestor claiming the same drop.
+ onDragOver={event => {
+ if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ }}
+ onDrop={event => {
+ const type = event.dataTransfer.getData(PALETTE_MIME);
+ if (type !== '') {
+ event.preventDefault();
+ event.stopPropagation();
+ placeBlock(nodeId, type);
+ }
+ }}
+ >
+ <ResizableGridLayout
+ layout={layout}
+ cols={columns}
+ // Left to its own `autoSize`, this element is only ever as tall as
+ // its own rows — meaning dropping "below" sparse content means
+ // finding the few remaining pixels this element actually renders
+ // across, past which there's no element left to fire a dragover on
+ // at all (`GridSurface`'s own plain fallback, below, takes over
+ // there instead — no live preview, an append at the end). A CSS
+ // floor is deliberately as far as this goes: it changes nothing
+ // about `layout` or what `isDroppable` tracks internally, only how
+ // tall the element hovered over already is.
+ //
+ // A live drop preview can still push the *content* height briefly
+ // past what this floor alone guarantees (the synthetic dropping
+ // item can land below everything currently on screen), and this
+ // element's own width is measured (by `WidthProvider`) on a
+ // `ResizeObserver` callback, not synchronously — so for a frame or
+ // two after that height change forces `GridSurface` to grow a
+ // vertical scrollbar, this element can still think it has the width
+ // from before that scrollbar took some of it back, and render
+ // wider than the space actually left. `GridSurface`'s own
+ // `overflow-x: hidden` (below) is what keeps that transient
+ // mismatch from ever showing up as a horizontal scrollbar rather
+ // than trying to keep the two measurements in perfect lockstep.
+ style={{ minHeight: '100%' }}
+ rowHeight={rowUnitPx}
+ margin={[gap, gap] as const}
+ containerPadding={[0, 0] as const}
+ // Explicit rather than relying on the (identical) default, since the
+ // obvious-looking alternative doesn't work: `compactType={null}`
+ // (no compaction) looks like it should mean "displace only the
+ // sibling actually being collided with," but react-grid-layout's
+ // own collision-resolution branch for `compactType === null`
+ // (`moveElementAwayFromCollision`'s "collisionNorth" case) re-adds
+ // a colliding sibling's full height on *every* drag frame the
+ // collision persists rather than settling once it's clear —
+ // confirmed live: dragging one tile so it merely brushes a
+ // neighbor's edge sent that neighbor hundreds of pixels away within
+ // a few mouse-move events. `"vertical"` resolves the same collision
+ // by moving the sibling down exactly once, by exactly its own
+ // height, every time.
+ compactType="vertical"
+ allowOverlap={false}
+ preventCollision={false}
+ // A palette drag is react-grid-layout's own concern from here on,
+ // not `GridSurface`'s: `isDroppable` is what makes it track the drag
+ // live and shows the preview `GridSurface`'s own CSS now styles
+ // (see that component's doc comment), with `handleDropDragOver`
+ // answering "how wide, here" on every frame and `handleExternalDrop`
+ // reading the result back out once the drag actually ends.
+ // `droppingItem` is only the shape's starting point — its `i` is
+ // never a real node, and its `w`/`h` are overwritten by
+ // `handleDropDragOver` before the preview is ever drawn — but
+ // react-grid-layout still wants one up front to size the very first
+ // frame against.
+ isDroppable
+ droppingItem={{
+ i: DROPPING_ITEM_ID,
+ x: 0,
+ y: 0,
+ w: columns,
+ h: FALLBACK_ROW_SPAN,
+ }}
+ onDropDragOver={handleDropDragOver}
+ onDrop={handleExternalDrop}
+ // All four corners. The top-right one used to sit under the remove
+ // control — react-grid-layout appends its handles after the block's
+ // own content, so a 20px handle sat over that button and took every
+ // click aimed at it. The single card-wide inset (see
+ // `BuildingBlockView`) moved the button further from the true
+ // corner than it sat before, which is what makes room for both here.
+ resizeHandles={['se', 'sw', 'nw', 'ne']}
+ // This is the only grid a dashboard has (see this component's own
+ // doc comment) — a nested container never renders another `RootGrid` —
+ // but a nested container (e.g. a `tabs` block) still renders its own
+ // `[data-container-id]` drop target, and a click anywhere inside one
+ // of those (on a leaf grandchild, or on the empty space between them)
+ // must never start dragging it as a single item here, or "drag one
+ // chart out of a tab" and "the whole tabs block comes with it"
+ // become indistinguishable at the DOM level. A leaf child
+ // (chart/markdown) never renders `data-container-id` itself, so this
+ // never affects dragging a leaf.
+ //
+ // `[data-block-resize]` is the same guard for a different gesture —
+ // `TabsBlock`'s own resize handle on a block flowed into a pane
+ // (see `FlowItem`). Without it, resizing that block and dragging the
+ // `tabs` item that holds it are indistinguishable for the same
+ // reason: both start with a pointer-down somewhere inside this grid
+ // item, and this component has no way to tell them apart except by
+ // where, exactly, that press landed.
+ //
+ // `[data-block-header-control]` is the same guard again for a
+ // type's own extra header control (see `blockHeaderControl`) — a
+ // press on `collapsible`'s expand/collapse toggle must toggle it,
+ // not start dragging the block it sits on.
+ draggableCancel="[data-container-id],[data-block-remove],[data-block-resize],[data-block-header-control]"
+ onDragStart={handleDragStart}
+ onDragStop={handleDragStop}
+ onResizeStart={handleResizeStart}
+ onResizeStop={handleResizeStop}
+ >
+ {children.map(childId => (
+ <BuildingBlockView key={childId} nodeId={childId} />
+ ))}
+ </ResizableGridLayout>
+ </GridSurface>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/blockHeaderControl.tsx b/superset-frontend/src/core/dashboard/blockHeaderControl.tsx
new file mode 100644
index 00000000000..7f2a5a83b1a
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blockHeaderControl.tsx
@@ -0,0 +1,144 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview A second control in a block's own header, beside the remove
+ * button — the header-side counterpart to `blockLabel`. Most block 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`
+ * 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`).
+ */
+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';
+
+/**
+ * How tall a collapsed block stays — just enough for `BuildingBlockView`'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
+ * `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).
+ */
+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
+ * yet returns it to exactly the size it was placed at.
+ */
+const DEFAULT_EXPANDED_ROW_SPAN = 4;
+
+function CollapsibleToggle({ nodeId }: { nodeId: string }): ReactElement {
+ const node = provider.getNode(nodeId);
+ const collapsed = Boolean(node?.props?.collapsed);
+
+ const toggle = (): void => {
+ const current = provider.getNode(nodeId);
+ if (!current) return;
+ if (collapsed) {
+ const restored =
+ (current.props?.expandedRowSpan as number | undefined) ??
+ DEFAULT_EXPANDED_ROW_SPAN;
+ provider.updateLayout(nodeId, { rowSpan: restored });
+ provider.updateProps(nodeId, { collapsed: false });
+ } else {
+ // The height about to be given up is saved so expanding again
+ // returns to it rather than always to the default — an author who
+ // grew a collapsible before collapsing it should not find it back at
+ // its original size on the way out.
+ provider.updateProps(nodeId, {
+ collapsed: true,
+ expandedRowSpan: current.layout?.rowSpan ?? DEFAULT_EXPANDED_ROW_SPAN,
+ });
+ provider.updateLayout(nodeId, { rowSpan: COLLAPSED_ROW_SPAN });
+ }
+ };
+
+ return (
+ <ActionButton
+ label={collapsed ? t('Expand block') : t('Collapse block')}
+ tooltip={collapsed ? t('Expand') : t('Collapse')}
+ placement="bottom"
+ dataTest={`block-collapse-toggle-${nodeId}`}
+ onClick={toggle}
+ icon={
+ collapsed ? (
+ <Icons.CaretRightOutlined iconSize="s" />
+ ) : (
+ <Icons.CaretDownOutlined iconSize="s" />
+ )
+ }
+ />
+ );
+}
+
+/**
+ * Appends a new slide and selects nothing itself — `CarouselBlock` 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
+ * anything that could hold or reach the active-slide state living inside
+ * `CarouselBlock`.
+ */
+function CarouselAddSlide({ nodeId }: { nodeId: string }): ReactElement {
+ const addSlide = (): void => {
+ const index = provider.getNode(nodeId)?.children?.length ?? 0;
+ provider.addBuildingBlock(nodeId, index, {
+ type: SLIDE_TYPE,
+ props: { label: untitledSlideLabel(index) },
+ });
+ };
+
+ return (
+ <ActionButton
+ label={t('Add slide')}
+ tooltip={t('Add slide')}
+ placement="bottom"
+ dataTest={`carousel-add-${nodeId}`}
+ onClick={addSlide}
+ icon={<Icons.PlusOutlined iconSize="s" />}
+ />
+ );
+}
+
+const HEADER_CONTROLS: Record<string, (nodeId: string) => ReactElement> = {
+ collapsible: nodeId => <CollapsibleToggle nodeId={nodeId} />,
+ carousel: nodeId => <CarouselAddSlide nodeId={nodeId} />,
+};
+
+/**
+ * A second control for the block 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(
+ type: string,
+ nodeId: string,
+): ReactElement | null {
+ return HEADER_CONTROLS[type]?.(nodeId) ?? null;
+}
diff --git a/superset-frontend/src/core/dashboard/blockLabel.test.ts b/superset-frontend/src/core/dashboard/blockLabel.test.ts
index 2b7b74f8e38..63c78ba8824 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.test.ts
+++ b/superset-frontend/src/core/dashboard/blockLabel.test.ts
@@ -47,10 +47,25 @@ test('a metric tile is named by the label it displays', () => {
);
});
-test('markdown is named by its opening words', () => {
+test('a tab pane is named by its own label', () => {
+ expect(blockLabel('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('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,
+ // 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' }),
- ).toBe('# Acme Corp Generated November');
+ ).toBe('');
+ expect(blockLabel('markdown', {})).toBe('');
});
test('a block with no name of its own is named by what it is', () => {
@@ -64,7 +79,6 @@ test('a name of nothing but spaces is no name', () => {
expect(
blockLabel('echarts', { echartsOptions: { title: { text: ' ' } } }),
).toBe('ECharts');
- expect(blockLabel('markdown', { content: '\n\n' })).toBe('Markdown');
});
test('a type nothing registered still says something', () => {
diff --git a/superset-frontend/src/core/dashboard/blockLabel.ts b/superset-frontend/src/core/dashboard/blockLabel.ts
index 1eea5ea1adf..9faa0727980 100644
--- a/superset-frontend/src/core/dashboard/blockLabel.ts
+++ b/superset-frontend/src/core/dashboard/blockLabel.ts
@@ -46,32 +46,61 @@ const echartsTitle = (props: Props): unknown => {
};
/**
- * Where a block type carries a name of its own.
+ * Where a block type carries a name of its own, distinct from what it
+ * renders.
*
- * Only the types that have one: everything else is named by its registration,
- * and an unlisted type — an extension's — falls through to that without
- * needing to be known here.
+ * `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
+ * 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
+ * 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
+ * named by its registration.
*/
const NAMED_BY: Record<string, (props: Props) => unknown> = {
- markdown: props => props?.content,
echarts: echartsTitle,
'metric-tile': props => props?.label,
+ tab: props => props?.label,
+ collapsible: props => props?.label,
+ slide: props => props?.label,
};
/**
- * What to call the block of `type` holding `props`.
+ * 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
+ * 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
+ * carries further.
+ */
+const UNNAMED: ReadonlySet<string> = new Set(['markdown', 'carousel']);
+
+/**
+ * What to call the block 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
* author gave it and the one they will look for. Only when there is none does
- * this fall back to the registered block name — "Markdown", "Table" — which
- * says what a block is rather than which one it is, and is worth nothing at
- * all when five of them sit in a column.
+ * 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
+ * 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 {
+ if (UNNAMED.has(type)) return '';
const own = NAMED_BY[type]?.(props);
if (typeof own === 'string' && own.trim() !== '') {
return own.trim().replace(/\s+/g, ' ');
diff --git a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
index 9b1b6f8d039..89a489be903 100644
--- a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx
@@ -18,7 +18,6 @@
*/
import { useEffect, useState } from 'react';
import type { dashboard as dashboardApi } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/theme';
import {
Flex,
Loading,
@@ -54,7 +53,6 @@ function deriveColumnDefs(columns: string[]): ColDef[] {
*/
export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
- const theme = useTheme();
const [rows, setRows] = useState<DataRow[] | null>(null);
const [columns, setColumns] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -115,7 +113,7 @@ export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
<Flex
align="center"
justify="center"
- style={{ width: '100%', height: '100%', padding: theme.padding }}
+ style={{ width: '100%', height: '100%' }}
>
<Typography.Text type="danger">{error}</Typography.Text>
</Flex>
diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx
deleted file mode 100644
index 79486bf01dc..00000000000
--- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { useCallback } from 'react';
-// The v1-compatible flat-props API (this component's own model: a plain
-// `cols`/`rowHeight`/`margin` grid, no responsive breakpoints — see the
-// design doc's "Explicitly deferred" section) lives at this subpath in v2;
-// the package's main entry exports only the newer composable API.
-import GridLayoutLegacy, { WidthProvider } from 'react-grid-layout/legacy';
-import type { Layout } from 'react-grid-layout/legacy';
-import 'react-grid-layout/css/styles.css';
-import type { dashboard as dashboardApi } from '@apache-superset/core';
-import { useTheme } from '@apache-superset/core/theme';
-import { provider, useDashboardRevision } from '../store';
-import { resolveGridMetrics } from '../layoutStyle';
-import { packChildLayout } from '../gridPacking';
-import { PALETTE_MIME, placeBlock } from '../placement';
-import BuildingBlockView from '../BuildingBlockView';
-
-type LayoutProps = dashboardApi.LayoutProps;
-
-const ResizableGridLayout = WidthProvider(GridLayoutLegacy);
-
-// DEBUG INSTRUMENTATION — kept in place on purpose while the drag/resize
-// interaction is still being worked out; remove only when explicitly asked
-// to. Logs which node id/type a gesture actually started on (so it's
-// obvious, e.g., when a click lands on a `canvas` row container instead of
-// the chart the user meant to grab) and what CanvasBlock ends up committing
-// at the end of it.
-const DASHBOARD_DRAG_DEBUG = true;
-function debugLog(event: string, detail: Record<string, unknown>) {
- if (!DASHBOARD_DRAG_DEBUG) return;
- // eslint-disable-next-line no-console
- console.debug(`[dashboard-drag] ${event}`, detail);
-}
-function describeNode(id: string | undefined) {
- if (!id) return undefined;
- const node = provider.getNode(id);
- return { id, type: node?.type };
-}
-
-/**
- * Finds the deepest dashboard container actually under a screen point,
- * ignoring `excludeEl`'s own subtree — the block being dragged might itself
- * be, or contain, a canvas, and a drop "onto itself" isn't a valid reparent
- * target. Every canvas's outer element carries `data-container-id`, so this
- * is the one piece of cross-container awareness a drag needs: which
- * container the pointer is over right now, at any nesting depth, without
- * any container needing to know about any other container's existence.
- *
- * `excludeEl`'s `pointer-events` is toggled off for the single synchronous
- * `elementFromPoint` call so the hit-test sees through the dragged element
- * to whatever is actually underneath it on screen (otherwise the dragged
- * element — positioned directly under the cursor by definition — would
- * always be its own top hit).
- */
-function findContainerIdAt(
- clientX: number,
- clientY: number,
- excludeEl: HTMLElement,
-): string | null {
- const previousPointerEvents = excludeEl.style.pointerEvents;
- excludeEl.style.pointerEvents = 'none';
- const hit = document.elementFromPoint(clientX, clientY);
- excludeEl.style.pointerEvents = previousPointerEvents;
- return (
- hit?.closest<HTMLElement>('[data-container-id]')?.dataset.containerId ??
- null
- );
-}
-
-/**
- * The built-in `canvas` building block: a grid container for child nodes,
- * backed by `react-grid-layout` — same recursive resolution its children
- * use (see `BuildingBlockView`), so nesting works identically whether a
- * child is another canvas, a leaf built-in block, or an extension's.
- *
- * All position/size math — including collision handling — is
- * `react-grid-layout`'s: resizing a block never shrinks a sibling, only
- * displaces it to the next open slot (clamped to `minW`/`minH: 1`), which
- * leaves every block's own authored (hand- or AI-set) span untouched. This
- * component's job is translating between the stored `col`/`row`/`colSpan`/
- * `rowSpan` schema (which allows a child to omit its position entirely, to
- * be auto-placed) and `react-grid-layout`'s own `{x, y, w, h}` — see
- * `packChildLayout` for the auto-placement piece RGL has no concept of — and
- * committing back to the store only once a gesture ends
- * (`onDragStop`/`onResizeStop`), never on every intermediate frame.
- *
- * Dragging a block 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 `react-grid-layout` (scoped to one grid instance) handles on
- * its own.
- */
-export default function CanvasBlock({ nodeId }: { nodeId: string }) {
- useDashboardRevision();
- const theme = useTheme();
- const node = provider.getNode(nodeId);
-
- const commitLayout = useCallback(
- (rglLayout: Layout) => {
- const updates: Record<string, Partial<LayoutProps>> = {};
- rglLayout.forEach(item => {
- updates[item.i] = {
- col: item.x + 1,
- row: item.y + 1,
- colSpan: item.w,
- rowSpan: item.h,
- };
- });
- debugLog('commitLayout', { containerId: nodeId, updates });
- provider.updateLayouts(updates);
- },
- [nodeId],
- );
-
- const handleDragStart = useCallback(
- (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
- debugLog('dragStart', {
- containerId: nodeId,
- dragged: describeNode(newItem?.i),
- });
- },
- [nodeId],
- );
-
- const handleResizeStart = useCallback(
- (_layout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
- debugLog('resizeStart', {
- containerId: nodeId,
- resized: describeNode(newItem?.i),
- });
- },
- [nodeId],
- );
-
- const handleResizeStop = useCallback(
- (rglLayout: Layout, _oldItem: unknown, newItem: { i: string } | null) => {
- debugLog('resizeStop', {
- containerId: nodeId,
- resized: describeNode(newItem?.i),
- });
- commitLayout(rglLayout);
- },
- [nodeId, commitLayout],
- );
-
- const handleDragStop = useCallback(
- (
- rglLayout: Layout,
- _oldItem: unknown,
- newItem: { i: string } | null,
- _placeholder: unknown,
- event: Event,
- element: HTMLElement | null,
- ) => {
- const { clientX, clientY } = event as MouseEvent;
- const targetContainerId =
- newItem && element && clientX != null && clientY != null
- ? findContainerIdAt(clientX, clientY, element)
- : null;
-
- debugLog('dragStop', {
- containerId: nodeId,
- dragged: describeNode(newItem?.i),
- targetContainerId,
- reparenting: !!targetContainerId && targetContainerId !== nodeId,
- });
-
- if (targetContainerId && targetContainerId !== nodeId && newItem) {
- try {
- // moveBuildingBlock 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(newItem.i, targetContainerId, destIndex);
- return;
- } catch {
- // Dropped onto itself or one of its own descendants — not a valid
- // reparent target. Fall through and keep it in this container.
- }
- }
-
- commitLayout(rglLayout);
- },
- [nodeId, commitLayout],
- );
-
- if (!node) return null;
-
- const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme);
- const children = node.children ?? [];
-
- const packed = packChildLayout(children, columns, provider.getNode);
- const layout: Layout = children.map(id => ({
- i: id,
- minW: 1,
- minH: 1,
- ...packed[id],
- }));
-
- return (
- <div
- data-container-id={nodeId}
- data-test="canvas-container"
- // Every container is a drop target, not just the root: a nested
- // section is exactly where an author means to put something when they
- // drag it there, and the stop is what makes the innermost container
- // under the pointer the one that takes it rather than every ancestor
- // claiming the same drop.
- onDragOver={event => {
- if (event.dataTransfer.types.includes(PALETTE_MIME)) {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'copy';
- }
- }}
- onDrop={event => {
- const type = event.dataTransfer.getData(PALETTE_MIME);
- if (type !== '') {
- event.preventDefault();
- event.stopPropagation();
- placeBlock(nodeId, type);
- }
- }}
- style={{ width: '100%', height: '100%', overflow: 'auto' }}
- >
- <ResizableGridLayout
- layout={layout}
- cols={columns}
- rowHeight={rowUnitPx}
- margin={[gap, gap] as const}
- containerPadding={[0, 0] as const}
- // Explicit rather than relying on the (identical) default, since the
- // obvious-looking alternative doesn't work: `compactType={null}`
- // (no compaction) looks like it should mean "displace only the
- // sibling actually being collided with," but react-grid-layout's
- // own collision-resolution branch for `compactType === null`
- // (`moveElementAwayFromCollision`'s "collisionNorth" case) re-adds
- // a colliding sibling's full height on *every* drag frame the
- // collision persists rather than settling once it's clear —
- // confirmed live: dragging one tile so it merely brushes a
- // neighbor's edge sent that neighbor hundreds of pixels away within
- // a few mouse-move events. `"vertical"` resolves the same collision
- // by moving the sibling down exactly once, by exactly its own
- // height, every time.
- compactType="vertical"
- allowOverlap={false}
- preventCollision={false}
- // Every corner but the top-right one, which a block spends on its
- // remove control. react-grid-layout appends its handles after the
- // block's own content, so a 20px handle sat over that button and took
- // every click aimed at it — `elementFromPoint` at the button's centre
- // returned the handle. A handle nobody can grab is worse than no
- // handle: the corner looks resizable and answers a drag that starts
- // one pixel away. Three corners still size a block, and the fourth
- // does the one thing that corner is labelled for.
- resizeHandles={['se', 'sw', 'nw']}
- // A nested canvas that declares its own `rowUnit` independently of
- // the outer `rowSpan` that placed it can end up needing more (or
- // less) height than that outer placement actually reserves — see
- // the `overflow: 'auto'` above, which is what keeps a too-tall
- // nested grid from bleeding into whatever comes after it instead of
- // scrolling internally. This selector is the drag-side half of the
- // same nesting concern: `[data-container-id]` matches only this
- // node's own children that are themselves a `canvas` — a click
- // anywhere inside one of those (on a leaf grandchild, or on the
- // empty space between them) must never start dragging that nested
- // canvas as a single item here, or "drag one chart in a KPI row"
- // and "the whole row comes with it" become indistinguishable at the
- // DOM level. A leaf child (chart/markdown) never renders
- // `data-container-id` itself, so this never affects dragging a
- // leaf — only a nested canvas becomes un-draggable as a whole via
- // a body click (it's still resizable via its own corner handles).
- draggableCancel="[data-container-id],[data-block-remove]"
- onDragStart={handleDragStart}
- onDragStop={handleDragStop}
- onResizeStart={handleResizeStart}
- onResizeStop={handleResizeStop}
- >
- {children.map(childId => (
- <BuildingBlockView key={childId} nodeId={childId} />
- ))}
- </ResizableGridLayout>
- </div>
- );
-}
diff --git a/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
new file mode 100644
index 00000000000..d643b8732ca
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/CarouselBlock.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { useRef, useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { EmptyState } from '@superset-ui/core/components';
+import { provider, useDashboardRevision } from '../store';
+import { PALETTE_MIME, placeBlock } from '../placement';
+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
+ * identical reason: nothing ever resolves one through
+ * `resolveBuildingBlockView`, since `CarouselBlock` renders a slide's
+ * children directly. It only needs to be a recognized container type so
+ * `addBuildingBlock` 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
+ * 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 —
+ * 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.
+ */
+const Root = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: 100%;
+ margin: 0 -${theme.padding}px -${theme.padding}px;
+ `}
+`;
+
+/**
+ * The dot strip itself — shown only once there is something to navigate
+ * between (see `CarouselBlock`'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.
+ */
+const NavColumn = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit * 3}px;
+ border-right: 1px solid ${theme.colorBorderSecondary};
+ overflow-y: auto;
+ `}
+`;
+
+const Dot = styled.button<{ $active: boolean }>`
+ ${({ theme, $active }) => css`
+ appearance: none;
+ border: none;
+ padding: 0;
+ flex: 0 0 auto;
+ width: ${theme.sizeUnit * 2}px;
+ height: ${theme.sizeUnit * 2}px;
+ border-radius: 50%;
+ background-color: ${$active ? theme.colorPrimary : theme.colorBorder};
+ cursor: pointer;
+ transition: background-color ${theme.motionDurationMid};
+
+ &:hover {
+ background-color: ${
+ $active ? theme.colorPrimary : theme.colorPrimaryBorder
+ };
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colorPrimaryBorder};
+ outline-offset: 2px;
+ }
+ `}
+`;
+
+/**
+ * Where a carousel with no slides yet still has to take a drop — a fresh
+ * `FlowContent` area has a `containerId` to drop into from the moment it
+ * exists (see its own comment), but a carousel with zero slides has no
+ * slide node at all yet for one to be the content of. This is that same
+ * drop target one level up: it makes the first `slide` itself, then hands
+ * the dropped type to `placeBlock` the same way `FlowContent` would have.
+ */
+const EmptyArea = styled.div`
+ width: 100%;
+ height: 100%;
+`;
+
+/** What a slide is called before an author (or the assistant) names it. */
+export const untitledSlideLabel = (index: number): string =>
+ t('Slide %s', index + 1);
+
+/**
+ * The built-in `carousel` building block — 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
+ * own: which slide is showing is this component's own concern, not a
+ * `layout` fact the document carries (composition/layout design doc).
+ *
+ * Unlike `tabs`, nothing here fills a carousel in on its own — a fresh one
+ * shows the same empty state a fresh pane would, rather than one slide
+ * already made for it, which is what keeps the dots from ever needing to
+ * appear over a single slide nobody asked for. They join the moment a first
+ * 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
+ * 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({
+ nodeId,
+}: {
+ nodeId: string;
+}): ReactElement | null {
+ useDashboardRevision();
+ const node = provider.getNode(nodeId);
+ const slides = node?.children ?? [];
+
+ const [activeSlideId, setActiveSlideId] = useState<string | undefined>(
+ slides[0],
+ );
+ const activeIsValid =
+ activeSlideId !== undefined && slides.includes(activeSlideId);
+ if (!activeIsValid && activeSlideId !== slides[0]) {
+ setActiveSlideId(slides[0]);
+ }
+
+ // A slide added since the last render — whether from `blockHeaderControl`'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
+ // it a callback.
+ const previousSlideCount = useRef(slides.length);
+ if (slides.length > previousSlideCount.current) {
+ setActiveSlideId(slides[slides.length - 1]);
+ }
+ previousSlideCount.current = slides.length;
+
+ if (!node) return null;
+
+ return (
+ <Root data-test={`carousel-${nodeId}`}>
+ {slides.length > 0 && (
+ <NavColumn
+ role="tablist"
+ aria-label={t('Carousel slides')}
+ aria-orientation="vertical"
+ >
+ {slides.map((slideId, index) => {
+ const active = slideId === activeSlideId;
+ return (
+ <Dot
+ key={slideId}
+ type="button"
+ role="tab"
+ tabIndex={0}
+ aria-selected={active}
+ aria-label={t('Slide %s', index + 1)}
+ $active={active}
+ data-test={`slide-${slideId}`}
+ onClick={() => setActiveSlideId(slideId)}
+ />
+ );
+ })}
+ </NavColumn>
+ )}
+ {activeSlideId ? (
+ <FlowContent
+ containerId={activeSlideId}
+ emptyTitle={t('Nothing on this slide yet')}
+ emptyDescription={t('Ask the assistant to add something here.')}
+ dataTest={`carousel-slide-${nodeId}`}
+ />
+ ) : (
+ <EmptyArea
+ data-test={`carousel-empty-${nodeId}`}
+ data-container-id={nodeId}
+ onDragOver={event => {
+ if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ }}
+ onDrop={event => {
+ const type = event.dataTransfer.getData(PALETTE_MIME);
+ if (type !== '') {
+ event.preventDefault();
+ event.stopPropagation();
+ const slideId = provider.addBuildingBlock(nodeId, 0, {
+ type: SLIDE_TYPE,
+ props: { label: untitledSlideLabel(0) },
+ });
+ placeBlock(slideId, type);
+ }
+ }}
+ >
+ <EmptyState
+ size="small"
+ image="empty.svg"
+ title={t('Nothing in this carousel yet')}
+ description={t('Ask the assistant to add a slide.')}
+ />
+ </EmptyArea>
+ )}
+ </Root>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
index b54007936da..48edfff97c0 100644
--- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx
@@ -109,7 +109,7 @@ echarts.use([
* renders it owns measuring the DOM. This measures both dimensions: a grid
* item's cell is always a definite pixel box (its column share of the
* container's width, its `rowSpan × rowUnit` height, both enforced by the
- * parent grid — see `CanvasBlock`), so there's no case here where a
+ * parent grid — see `RootGrid`), so there's no case here where a
* measured size is ambiguous or collapses to zero the way an unconstrained
* flex height could.
*/
@@ -260,7 +260,7 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) {
<Flex
align="center"
justify="center"
- style={{ width: '100%', height: '100%', padding: theme.padding }}
+ style={{ width: '100%', height: '100%' }}
>
<Typography.Text type="danger">{error}</Typography.Text>
</Flex>
diff --git a/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx
new file mode 100644
index 00000000000..52bc83d3186
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/CollapsibleBlock.tsx
@@ -0,0 +1,97 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { provider, useDashboardRevision } from '../store';
+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
+ * reason. The top is left alone, since this box already starts below the
+ * card's header rather than at its true top edge.
+ */
+const Root = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ margin: 0 -${theme.padding}px -${theme.padding}px;
+ `}
+`;
+
+/**
+ * The built-in `collapsible` building block — 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
+ * 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
+ * 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 —
+ * 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
+ * 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.
+ *
+ * There is no intermediate pane node the way `tabs` has one per tab — one
+ * child is already the simplest container `FlowContent` can hold, so
+ * `nodeId` itself is the flow area's own `containerId`. `accepts` closes
+ * 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({
+ nodeId,
+}: {
+ nodeId: string;
+}): ReactElement | null {
+ useDashboardRevision();
+ const node = provider.getNode(nodeId);
+ if (!node) return null;
+
+ const collapsed = Boolean(node.props?.collapsed);
+ if (collapsed) return null;
+
+ const children = node.children ?? [];
+
+ return (
+ <Root data-test={`collapsible-${nodeId}`}>
+ <FlowContent
+ containerId={nodeId}
+ accepts={children.length === 0}
+ emptyTitle={t('Nothing here yet')}
+ emptyDescription={t('Ask the assistant to add something here.')}
+ dataTest={`collapsible-content-${nodeId}`}
+ />
+ </Root>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
index 0a6c3910518..c2f2ad1ac38 100644
--- a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useTheme } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components';
import { provider, useDashboardRevision } from '../store';
@@ -28,7 +27,6 @@ import { provider, useDashboardRevision } from '../store';
*/
export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
- const theme = useTheme();
const node = provider.getNode(nodeId);
if (!node) return null;
@@ -37,10 +35,9 @@ export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
style={{
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
- // inside the frame rather than over it.
- padding: theme.padding,
+ // Surface, border, corners and inset all belong to the card
+ // `BuildingBlockView` draws around this block 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/blocks/MetricTileBlock.tsx
index e5a45c6ac95..5d11e0677a6 100644
--- a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
+++ b/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx
@@ -153,10 +153,9 @@ export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
// block — always a definite pixel box, same as `ChartBlock`.
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
- // inside the frame rather than over it.
- padding: theme.padding,
+ // Surface, border, corners and inset all belong to the card
+ // `BuildingBlockView` draws around this block 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/blocks/TabsBlock.test.tsx
new file mode 100644
index 00000000000..bf25c8b11f1
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/TabsBlock.test.tsx
@@ -0,0 +1,226 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
+import DashboardProvider from '../DashboardProvider';
+import { registerBuiltInBuildingBlocks } from '../registerBuiltInBuildingBlocks';
+import TabsBlock from './TabsBlock';
+
+const provider = DashboardProvider.getInstance();
+
+beforeAll(() => {
+ registerBuiltInBuildingBlocks();
+});
+
+beforeEach(() => {
+ provider.reset();
+});
+
+/** 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' });
+};
+
+test('a freshly placed tabs block already has one tab, selected', () => {
+ const tabsId = createTabs();
+ render(<TabsBlock nodeId={tabsId} />);
+
+ // A tabs block 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.
+ const tab = screen.getByRole('tab', { name: 'Tab 1' });
+ expect(tab).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Nothing in this tab yet')).toBeVisible();
+
+ const paneId = provider.getNode(tabsId)?.children?.[0] as string;
+ expect(provider.getNode(paneId)?.props?.label).toBe('Tab 1');
+ expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
+});
+
+test('adding a tab is named after its own position, not the count at click time', () => {
+ const tabsId = createTabs();
+ render(<TabsBlock nodeId={tabsId} />);
+
+ // Tab 1 already exists (see the test above) — each click adds the next.
+ fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+ fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+
+ expect(screen.getByRole('tab', { name: 'Tab 1' })).toBeVisible();
+ expect(screen.getByRole('tab', { name: 'Tab 2' })).toBeVisible();
+ expect(screen.getByRole('tab', { name: 'Tab 3' })).toBeVisible();
+});
+
+test('the only tab offers no way to remove itself', () => {
+ const tabsId = createTabs();
+ render(<TabsBlock nodeId={tabsId} />);
+
+ const paneId = provider.getNode(tabsId)?.children?.[0] as string;
+
+ // Removing it would leave a blank tabs block the layout effect would
+ // immediately refill anyway, which reads as the control having silently
+ // done nothing.
+ expect(screen.queryByTestId(`tab-remove-${paneId}`)).not.toBeInTheDocument();
+});
+
+test('a second tab can be removed, falling back to the remaining one', () => {
+ const tabsId = createTabs();
+ render(<TabsBlock nodeId={tabsId} />);
+ const firstPaneId = provider.getNode(tabsId)?.children?.[0] as string;
+
+ fireEvent.click(screen.getByTestId(`tabs-add-${tabsId}`));
+ const secondTab = screen.getByRole('tab', { name: 'Tab 2' });
+ fireEvent.click(secondTab);
+ fireEvent.click(
+ screen.getByTestId(`tab-remove-${provider.getNode(tabsId)?.children?.[1]}`),
+ );
+
+ expect(screen.queryByRole('tab', { name: 'Tab 2' })).not.toBeInTheDocument();
+ expect(provider.getNode(tabsId)?.children).toEqual([firstPaneId]);
+ expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+});
+
+test('dropping a palette block onto the active pane places it there', () => {
+ const tabsId = createTabs();
+ const paneId = provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
+ });
+ render(<TabsBlock nodeId={tabsId} />);
+
+ const data = new Map([
+ ['application/x-dashboard-building-block', 'markdown'],
+ ]);
+ fireEvent.drop(screen.getByTestId(`tabs-panes-${tabsId}`), {
+ dataTransfer: {
+ types: [...data.keys()],
+ getData: (key: string) => data.get(key) ?? '',
+ dropEffect: '',
+ effectAllowed: '',
+ },
+ });
+
+ expect(provider.getNode(paneId)?.children).toHaveLength(1);
+ const droppedId = provider.getNode(paneId)?.children?.[0] as string;
+ expect(provider.getNode(droppedId)?.type).toBe('markdown');
+});
+
+test('clicking a tab shows its own content and hides the other panes', async () => {
+ const tabsId = createTabs();
+ const firstPane = provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
+ });
+ const secondPane = provider.addBuildingBlock(tabsId, 1, {
+ type: 'tab',
+ props: { label: 'Detail' },
+ });
+ provider.addBuildingBlock(firstPane, 0, {
+ type: 'markdown',
+ props: { content: 'Overview content' },
+ });
+ provider.addBuildingBlock(secondPane, 0, {
+ type: 'markdown',
+ props: { content: 'Detail content' },
+ });
+ render(<TabsBlock nodeId={tabsId} />);
+
+ // The first pane is active by default. `findByText` rather than
+ // `getByText`: `SafeMarkdown` lazy-loads `react-markdown` itself and
+ // renders nothing until that resolves, so the text is not necessarily
+ // there yet on the tick right after `render`.
+ expect(await screen.findByText('Overview content')).toBeVisible();
+ expect(screen.queryByText('Detail content')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
+
+ expect(screen.queryByText('Overview content')).not.toBeInTheDocument();
+ 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', () => {
+ const tabsId = createTabs();
+ const pane = provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
+ });
+ const chartId = provider.addBuildingBlock(pane, 0, {
+ type: 'markdown',
+ props: { content: 'Chart stand-in' },
+ });
+ render(<TabsBlock 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.
+ expect(handle).not.toHaveAttribute('aria-valuenow');
+
+ handle.focus();
+ fireEvent.keyDown(handle, { key: 'ArrowDown' });
+
+ // The first resize is what fixes it to an explicit size — measured off
+ // the rendered box in a real browser, or `DEFAULT_FLOW_ITEM_HEIGHT` here,
+ // where nothing is actually laid out to measure.
+ expect(handle).toHaveAttribute('aria-valuenow', '376');
+ expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(376);
+});
+
+test('a flowed block cannot be shrunk past the minimum height', () => {
+ const tabsId = createTabs();
+ const pane = provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
+ });
+ const chartId = provider.addBuildingBlock(pane, 0, {
+ type: 'markdown',
+ layout: { rowSpan: 124 },
+ props: { content: 'Chart stand-in' },
+ });
+ render(<TabsBlock nodeId={tabsId} />);
+
+ const handle = screen.getByTestId(`flow-resize-${chartId}`);
+ handle.focus();
+ fireEvent.keyDown(handle, { key: 'ArrowUp' });
+ fireEvent.keyDown(handle, { key: 'ArrowUp' });
+
+ expect(provider.getNode(chartId)?.layout?.rowSpan).toBe(120);
+});
+
+test('removing the active pane falls back to the first remaining tab', () => {
+ const tabsId = createTabs();
+ provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
+ });
+ const detailPane = provider.addBuildingBlock(tabsId, 1, {
+ type: 'tab',
+ props: { label: 'Detail' },
+ });
+ render(<TabsBlock nodeId={tabsId} />);
+
+ fireEvent.click(screen.getByRole('tab', { name: 'Detail' }));
+ act(() => provider.removeBuildingBlock(detailPane));
+
+ expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+});
diff --git a/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx b/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
new file mode 100644
index 00000000000..fec50add9e7
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/TabsBlock.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { useLayoutEffect, useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { ActionButton } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+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
+ * `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
+ * `registerContainerType` in `DashboardProvider`.
+ */
+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
+ * 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
+ * header would. `FlowContent` keeps its own inset (see its own comment) for
+ * the content flowed into a pane, which is the single-thing case the
+ * padding was written for; this cancels that same padding for the chrome
+ * around it — on three sides only. The top is not the card's own padding to
+ * begin with:
+ * this box starts right where the header already ends, not at the card's
+ * true top edge, so cancelling it as well pulled the tab bar up past the
+ * header instead of just to the card's left/right/bottom edges, overlapping
+ * the two.
+ */
+const Root = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ margin: 0 -${theme.padding}px -${theme.padding}px;
+ `}
+`;
+
+const TabBar = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit}px;
+ flex: 0 0 auto;
+ padding: 0 ${theme.sizeUnit * 2}px;
+ border-bottom: 1px solid ${theme.colorBorderSecondary};
+ overflow-x: auto;
+ `}
+`;
+
+const TabButton = styled.button<{ $active: boolean }>`
+ ${({ theme, $active }) => css`
+ appearance: none;
+ border: none;
+ background: none;
+ flex: 0 0 auto;
+ padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
+ font-size: ${theme.fontSizeSM}px;
+ font-weight: ${$active ? theme.fontWeightStrong : theme.fontWeightNormal};
+ color: ${$active ? theme.colorPrimaryText : theme.colorTextSecondary};
+ border-bottom: 2px solid ${$active ? theme.colorPrimary : 'transparent'};
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ color ${theme.motionDurationMid},
+ border-color ${theme.motionDurationMid};
+
+ &:hover {
+ color: ${theme.colorPrimaryText};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colorPrimaryBorder};
+ outline-offset: -2px;
+ }
+ `}
+`;
+
+/**
+ * A tab and its own remove control, as siblings rather than one nested
+ * inside the other.
+ *
+ * `TabButton` is a real `<button>` — switching tabs is what most presses on
+ * it mean, and a button is what answers Enter/Space and reads as one to a
+ * 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
+ * and its own remove control.
+ */
+const TabItem = styled.span`
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+`;
+
+/** What a pane is called before an author (or the assistant) names it. */
+const untitledLabel = (index: number): string => t('Tab %s', index + 1);
+
+/**
+ * The built-in `tabs` building block — 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`),
+ * 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
+ * composition/layout design doc, that's the point — a container answers
+ * "how do I arrange what's inside me" for itself, and this is simply one
+ * answer among many, no more privileged than the root's own grid.
+ *
+ * Which pane is *active* is intentionally not persisted: it's a fact about
+ * who's looking at the dashboard right now, not about the dashboard itself
+ * (the same reasoning `DashboardProvider`'s own `selection` field is
+ * host-internal rather than part of a node). It resets to the first pane
+ * 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({
+ nodeId,
+}: {
+ nodeId: string;
+}): ReactElement | null {
+ useDashboardRevision();
+ const node = provider.getNode(nodeId);
+ const panes = node?.children ?? [];
+
+ const [activeTabId, setActiveTabId] = useState<string | undefined>(panes[0]);
+ const activeIsValid =
+ activeTabId !== undefined && panes.includes(activeTabId);
+ if (!activeIsValid && activeTabId !== panes[0]) {
+ setActiveTabId(panes[0]);
+ }
+
+ const addTab = (): void => {
+ const id = provider.addBuildingBlock(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 —
+ // 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.
+ useLayoutEffect(() => {
+ if (node && panes.length === 0) {
+ addTab();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [nodeId, node, panes.length]);
+
+ if (!node) return null;
+
+ return (
+ <Root data-test={`tabs-${nodeId}`}>
+ <TabBar role="tablist" aria-label={t('Tabs')}>
+ {panes.map((paneId, index) => {
+ const pane = provider.getNode(paneId);
+ const label =
+ (pane?.props?.label as string | undefined) || untitledLabel(index);
+ const active = paneId === activeTabId;
+ return (
+ <TabItem key={paneId}>
+ <TabButton
+ type="button"
+ role="tab"
+ tabIndex={0}
+ aria-selected={active}
+ $active={active}
+ data-test={`tab-${paneId}`}
+ onClick={() => setActiveTabId(paneId)}
+ >
+ {label}
+ </TabButton>
+ {/* Offered once there is a second tab to fall back to —
+ removing the only one just left a blank tabs block the
+ effect above would immediately refill, which reads as the
+ control having silently done nothing. */}
+ {panes.length > 1 && (
+ <ActionButton
+ label={t('Remove tab')}
+ tooltip={t('Remove tab')}
+ placement="bottom"
+ dataTest={`tab-remove-${paneId}`}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => provider.removeBuildingBlock(paneId)}
+ />
+ )}
+ </TabItem>
+ );
+ })}
+ <ActionButton
+ label={t('Add tab')}
+ tooltip={t('Add tab')}
+ placement="bottom"
+ dataTest={`tabs-add-${nodeId}`}
+ icon={<Icons.PlusOutlined iconSize="s" />}
+ onClick={addTab}
+ />
+ </TabBar>
+ {/* `activeTabId` is only briefly undefined, on the very first render
+ before the layout effect above fills the tabs block in — skipping
+ `FlowContent` for that one tick is what keeps this from ever
+ needing a pane id it does not have yet. */}
+ {activeTabId && (
+ <FlowContent
+ containerId={activeTabId}
+ emptyTitle={t('Nothing in this tab yet')}
+ emptyDescription={t('Ask the assistant to add something here.')}
+ dataTest={`tabs-panes-${nodeId}`}
+ />
+ )}
+ </Root>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/blocks/flowContent.tsx b/superset-frontend/src/core/dashboard/blocks/flowContent.tsx
new file mode 100644
index 00000000000..bbbd8207528
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/blocks/flowContent.tsx
@@ -0,0 +1,344 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview A container's "flow" — one child under the next, each
+ * resizable and the whole thing a drop target — shared by every built-in
+ * container that is not the root's own grid (`tabs`, `collapsible`,
+ * `carousel`). Each of those still decides for itself how many flow areas
+ * it has and which one is currently showing (a single one for
+ * `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
+ * palette, exactly like `RootGrid`'s own drop target for the reason given
+ * on `FlowContent` below.
+ */
+import { useEffect, useRef, useState } from 'react';
+import type { KeyboardEvent, PointerEvent, ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+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';
+
+/**
+ * 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`
+ * 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. */
+export const MIN_FLOW_ITEM_HEIGHT = 120;
+
+/** How far one arrow press resizes a block. */
+const RESIZE_STEP = 16;
+
+/**
+ * A flowed block's own resize handle.
+ *
+ * `BuildingBlockView` renders whatever it is given as `children` last, after
+ * its own header and content — the slot `react-grid-layout` injects its
+ * handles through on the root's own grid. A flowed block has no grid, and so
+ * no react-grid-layout handle of its own, but the slot serves the same
+ * purpose for the same reason: this needs to sit inside the block's own box,
+ * on top of it, without becoming part of what the block itself renders.
+ *
+ * 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
+ * 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.
+ */
+const ResizeGrip = styled.div`
+ ${({ theme }) => css`
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: ${theme.sizeUnit * 2}px;
+ cursor: row-resize;
+ touch-action: none;
+ z-index: 1;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 2px;
+ background-color: transparent;
+ transition: background-color ${theme.motionDurationMid};
+ }
+
+ &:hover::after,
+ &:focus-visible::after,
+ &:active::after {
+ background-color: ${theme.colorPrimary};
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+ `}
+`;
+
+/**
+ * One block, 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 —
+ * reused rather than a field of its own, for the same reason `colSpan` is
+ * meaningless outside a grid and nobody invented a second name for "this
+ * many columns" to go with it: `rowSpan` already means "how tall," and a
+ * flow container is free to read it in its own unit (a pixel, here) the same
+ * 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
+ * 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
+ * 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
+ * (`flex: 0 0 auto`) — a size someone chose is never overridden by whatever
+ * space happens to be around it.
+ *
+ * The drag is tracked locally and committed with `provider.updateLayout`
+ * only once it ends, the same reason `RootGrid` commits a resize on
+ * `onResizeStop` rather than on every intermediate frame: a revision tick —
+ * and the re-render of everything subscribed to it — per pixel dragged
+ * would make the drag itself the slow part of resizing.
+ */
+export function FlowItem({
+ nodeId,
+ height,
+}: {
+ nodeId: string;
+ height: number | undefined;
+}): ReactElement {
+ const [liveHeight, setLiveHeight] = useState(height);
+ // What was accepted replaces the draft, because the draft was a view of
+ // it: a resize the assistant makes while this is on screen has to show.
+ useEffect(() => setLiveHeight(height), [height]);
+ const from = useRef<{ y: number; height: number } | null>(null);
+ 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
+ // 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`
+ // (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 => {
+ if (liveHeight !== undefined) return liveHeight;
+ return (
+ wrapperRef.current?.getBoundingClientRect().height ||
+ DEFAULT_FLOW_ITEM_HEIGHT
+ );
+ };
+
+ const startDrag = (event: PointerEvent<HTMLDivElement>): void => {
+ // This block sits inside the root's own grid (the flow's own container
+ // is a grid item like any other), whose react-draggable instance
+ // 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
+ // `draggableCancel` half of the same guard (see `BuildingBlockView`'s
+ // `data-block-remove`, which exists for the identical reason).
+ event.stopPropagation();
+ from.current = { y: event.clientY, height: currentHeight() };
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ };
+
+ const drag = (event: PointerEvent<HTMLDivElement>): void => {
+ if (from.current !== null) {
+ setLiveHeight(
+ Math.max(
+ MIN_FLOW_ITEM_HEIGHT,
+ from.current.height + event.clientY - from.current.y,
+ ),
+ );
+ }
+ };
+
+ 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
+ // to whatever `currentHeight` happened to measure at that instant.
+ if (from.current !== null && liveHeight !== undefined) {
+ provider.updateLayout(nodeId, { rowSpan: liveHeight });
+ }
+ from.current = null;
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ };
+
+ const resize = (event: KeyboardEvent<HTMLDivElement>): void => {
+ const moves: Record<string, (current: number) => number> = {
+ ArrowDown: current => current + RESIZE_STEP,
+ ArrowUp: current => Math.max(MIN_FLOW_ITEM_HEIGHT, current - RESIZE_STEP),
+ };
+ const move = moves[event.key];
+ if (move !== undefined) {
+ event.preventDefault();
+ const next = move(currentHeight());
+ setLiveHeight(next);
+ provider.updateLayout(nodeId, { rowSpan: next });
+ }
+ };
+
+ return (
+ <div
+ ref={wrapperRef}
+ style={
+ liveHeight === undefined
+ ? {
+ width: '100%',
+ flex: '1 1 auto',
+ minHeight: MIN_FLOW_ITEM_HEIGHT,
+ position: 'relative',
+ }
+ : {
+ width: '100%',
+ height: liveHeight,
+ flex: '0 0 auto',
+ position: 'relative',
+ }
+ }
+ >
+ <BuildingBlockView
+ 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-valuenow={liveHeight}
+ aria-valuemin={MIN_FLOW_ITEM_HEIGHT}
+ tabIndex={0}
+ data-test={`flow-resize-${nodeId}`}
+ data-block-resize
+ onPointerDown={startDrag}
+ onPointerMove={drag}
+ onPointerUp={endDrag}
+ onKeyDown={resize}
+ />
+ </BuildingBlockView>
+ </div>
+ );
+}
+
+const FlowArea = styled.div`
+ ${({ theme }) => css`
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ display: flex;
+ 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
+ sitting a comfortable distance inside the container rather than
+ pressed against its edges. */
+ padding: ${theme.sizeUnit * 4}px;
+ `}
+`;
+
+/**
+ * One flow area's worth of content: a resizable stack of blocks, 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
+ * 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
+ * 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,
+ * once in this area and once on the root.
+ */
+export function FlowContent({
+ containerId,
+ emptyTitle,
+ emptyDescription,
+ accepts = true,
+ dataTest,
+}: {
+ containerId: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ accepts?: boolean;
+ dataTest?: string;
+}): ReactElement {
+ const children = provider.getNode(containerId)?.children ?? [];
+
+ return (
+ <FlowArea
+ data-test={dataTest}
+ data-container-id={containerId}
+ onDragOver={event => {
+ if (accepts && event.dataTransfer.types.includes(PALETTE_MIME)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ }}
+ onDrop={event => {
+ const type = event.dataTransfer.getData(PALETTE_MIME);
+ if (accepts && type !== '') {
+ event.preventDefault();
+ event.stopPropagation();
+ placeBlock(containerId, type);
+ }
+ }}
+ >
+ {children.length === 0 && (
+ <EmptyState
+ size="small"
+ image="empty.svg"
+ title={emptyTitle}
+ description={emptyDescription}
+ />
+ )}
+ {children.map(childId => (
+ <FlowItem
+ key={childId}
+ nodeId={childId}
+ height={provider.getNode(childId)?.layout?.rowSpan}
+ />
+ ))}
+ </FlowArea>
+ );
+}
diff --git a/superset-frontend/src/core/dashboard/gridPacking.ts b/superset-frontend/src/core/dashboard/gridPacking.ts
index 19a985c3a05..cb70fef7afd 100644
--- a/superset-frontend/src/core/dashboard/gridPacking.ts
+++ b/superset-frontend/src/core/dashboard/gridPacking.ts
@@ -110,6 +110,81 @@ export function packChildLayout(
return result;
}
+/** A rectangular span a block being dropped in has room for — see `availableDropSpan`. */
+export interface AvailableDropSpan {
+ w: number;
+ h: number;
+}
+
+/**
+ * How big a block 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 (see its own
+ * `handleDropDragOver`), 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.
+ *
+ * Open space returns exactly as wide a span as that row has free, capped at
+ * `columns` — a wholly empty grid included, since nothing anywhere is
+ * occupied there either — and exactly as tall a span as the rows below 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 `minW` is 1 (see `RootGrid`'s own
+ * `layout` construction), 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.
+ *
+ * Directly over another block there is no *beside* to speak of, only
+ * *above* or *below* it (which `compactType="vertical"` resolves the same
+ * way it already does for repositioning an existing block), so that returns
+ * the full row at `maxRowSpan` instead.
+ */
+export function availableDropSpan(
+ packed: Record<string, PackedRect>,
+ columns: number,
+ cursorCol: number,
+ cursorRow: number,
+ maxRowSpan: number,
+): AvailableDropSpan {
+ const occupied = new Set<string>();
+ Object.values(packed).forEach(rect => {
+ for (let dy = 0; dy < rect.h; dy += 1) {
+ for (let dx = 0; dx < rect.w; dx += 1) {
+ occupied.add(`${rect.x + dx},${rect.y + dy}`);
+ }
+ }
+ });
+
+ if (occupied.has(`${cursorCol},${cursorRow}`)) {
+ return { w: columns, h: maxRowSpan };
+ }
+
+ let left = cursorCol;
+ while (left > 0 && !occupied.has(`${left - 1},${cursorRow}`)) {
+ left -= 1;
+ }
+ let right = cursorCol;
+ while (right < columns - 1 && !occupied.has(`${right + 1},${cursorRow}`)) {
+ right += 1;
+ }
+ const w = right - left + 1;
+
+ const rowIsFree = (row: number): boolean => {
+ for (let dx = 0; dx < w; dx += 1) {
+ if (occupied.has(`${left + dx},${row}`)) return false;
+ }
+ return true;
+ };
+ let bottom = cursorRow;
+ while (bottom - cursorRow + 1 < maxRowSpan && rowIsFree(bottom + 1)) {
+ bottom += 1;
+ }
+ const h = bottom - cursorRow + 1;
+
+ return { w, h };
+}
+
function rectsOverlap(
a: { col: number; row: number; colSpan: number; rowSpan: number },
b: { col: number; row: number; colSpan: number; rowSpan: number },
@@ -127,7 +202,7 @@ function rectsOverlap(
* (both `col` and `row` set) by pushing a later-declared one straight down,
* one row at a time, until it no longer overlaps an earlier one — the same
* "displace, never shrink" rule interactive resize/drag already gets from
- * `react-grid-layout` (see `CanvasBlock`), applied here for the
+ * `react-grid-layout` (see `RootGrid`), applied here for the
* programmatic placement path (`DashboardProvider.addBuildingBlock`/
* `updateLayout`, which an extension's AI tools call directly) so both give
* the same "nothing ends up stuck overlapping" guarantee, not just the one
diff --git a/superset-frontend/src/core/dashboard/layoutStyle.ts b/superset-frontend/src/core/dashboard/layoutStyle.ts
index f151c26db34..9d356e4ab0f 100644
--- a/superset-frontend/src/core/dashboard/layoutStyle.ts
+++ b/superset-frontend/src/core/dashboard/layoutStyle.ts
@@ -27,7 +27,7 @@ export const DEFAULT_COLUMNS = 24;
const DEFAULT_GAP = 16;
-/** A container's resolved grid geometry, in the plain numbers `CanvasBlock` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */
+/** A container's resolved grid geometry, in the plain numbers `RootGrid` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */
export interface GridMetrics {
columns: number;
gap: number;
diff --git a/superset-frontend/src/core/dashboard/placement.ts b/superset-frontend/src/core/dashboard/placement.ts
index 80830d06b96..61109f29eb0 100644
--- a/superset-frontend/src/core/dashboard/placement.ts
+++ b/superset-frontend/src/core/dashboard/placement.ts
@@ -28,7 +28,7 @@
* it, and the difference is invisible until someone hits it.
*/
-import { isContainerType } from './DashboardProvider';
+import { GRID_TYPE, isContainerType } from './DashboardProvider';
import { DEFAULT_COLUMNS } from './layoutStyle';
import { provider } from './store';
@@ -41,6 +41,50 @@ import { provider } from './store';
*/
export const PALETTE_MIME = 'application/x-dashboard-building-block';
+/**
+ * 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
+ * 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
+ * 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
+ * `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
+ * once they've confirmed the parent actually is the root's own grid.
+ */
+const DEFAULT_ROW_SPAN: Record<string, number> = {
+ markdown: 5,
+ echarts: 8,
+ 'ag-grid-table': 9,
+ 'metric-tile': 4,
+ tabs: 6,
+ collapsible: 5,
+ carousel: 5,
+};
+
+/**
+ * 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
+ * rather than this module's per-type table (see `placeBlockAt`): a drag
+ * already answers where a block 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
+ * clipped or scrolling before anyone has seen what it renders.
+ */
+export const FALLBACK_ROW_SPAN = 6;
+
/**
* Places a new block of `type` at the end of `parentId`'s children and
* selects it, returning its id.
@@ -50,21 +94,68 @@ export const PALETTE_MIME = 'application/x-dashboard-building-block';
* set before anything can go inside it. Selecting what was just placed is
* what brings its properties forward: placing something is the moment you
* want to configure it.
+ *
+ * `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
+ * whatever room the container actually has, rather than a grid-row number
+ * misread as a pixel count.
*/
export function placeBlock(parentId: string, type: string): string {
const index = provider.getNode(parentId)?.children?.length ?? 0;
+ const onRootGrid = provider.getNode(parentId)?.type === GRID_TYPE;
+ const rowSpan = onRootGrid
+ ? (DEFAULT_ROW_SPAN[type] ?? FALLBACK_ROW_SPAN)
+ : undefined;
+ const id = provider.addBuildingBlock(parentId, index, {
+ type,
+ layout: isContainerType(type)
+ ? { columns: DEFAULT_COLUMNS, gap: 16, colSpan: DEFAULT_COLUMNS, rowSpan }
+ : { rowSpan },
+ });
+ provider.setSelection(id);
+ return id;
+}
+
+/**
+ * Places a new block 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
+ * *where* (and how wide, next to whatever it landed beside) was the entire
+ * point of the gesture.
+ *
+ * `position` arrives already resolved: react-grid-layout ran its own
+ * collision avoidance live, during the drag itself, the same as it does for
+ * repositioning an existing block (see `RootGrid`'s own `isDroppable`
+ * wiring) — nothing here recomputes a position, only writes the one already
+ * shown as the drop's own preview.
+ *
+ * `index`, unlike `placeBlock`'s own implicit "at the end," is the caller's
+ * 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
+ * order than the row it is displacing, or collision resolution reads it
+ * backwards and pushes the new block 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
+ * resolves reading order too, rather than this module re-deriving it from
+ * scratch here.
+ */
+export function placeBlockAt(
+ parentId: string,
+ type: string,
+ index: number,
+ position: { col: number; row: number; colSpan: number; rowSpan: number },
+): string {
const id = provider.addBuildingBlock(parentId, index, {
type,
- ...(isContainerType(type)
- ? {
- layout: {
- columns: DEFAULT_COLUMNS,
- gap: 16,
- colSpan: DEFAULT_COLUMNS,
- rowSpan: 4,
- },
- }
- : {}),
+ layout: isContainerType(type)
+ ? { columns: DEFAULT_COLUMNS, gap: 16, ...position }
+ : { ...position },
});
provider.setSelection(id);
return id;
diff --git a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts b/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
index 5352ec5f72e..06f6d8a3b85 100644
--- a/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
+++ b/superset-frontend/src/core/dashboard/registerBuiltInBuildingBlocks.ts
@@ -18,34 +18,34 @@
*/
import { views } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
-import CanvasBlock from './blocks/CanvasBlock';
+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';
let registered = false;
/**
* Registers the built-in block types through the exact same `views` call an
- * extension uses to contribute one of its own — canvas/markdown/echarts/
- * ag-grid-table/metric-tile have no special status in the render path (see
- * `BuildingBlockView`), they're just pre-registered here before anything
- * else has a chance to render a dashboard node.
+ * 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
+ * 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
+ * directly rather than through this registry (the same reason `tab`, below,
+ * isn't registered either — see `TabsBlock`).
*/
export function registerBuiltInBuildingBlocks(): void {
if (registered) return;
registered = true;
- views.registerView(
- {
- id: 'canvas',
- name: 'Canvas',
- description: 'A flex container for other building blocks.',
- },
- DASHBOARD_BUILDING_BLOCKS_LOCATION,
- CanvasBlock,
- );
views.registerView(
{
id: 'markdown',
@@ -82,4 +82,46 @@ export function registerBuiltInBuildingBlocks(): void {
DASHBOARD_BUILDING_BLOCKS_LOCATION,
MetricTileBlock,
);
+ views.registerView(
+ {
+ id: 'tabs',
+ name: 'Tabs',
+ description: 'Groups building blocks into switchable tabs.',
+ },
+ DASHBOARD_BUILDING_BLOCKS_LOCATION,
+ TabsBlock,
+ );
+ views.registerView(
+ {
+ id: 'collapsible',
+ name: 'Collapsible',
+ description: 'Holds a single building block behind a show/hide toggle.',
+ },
+ DASHBOARD_BUILDING_BLOCKS_LOCATION,
+ CollapsibleBlock,
+ );
+ views.registerView(
+ {
+ id: 'carousel',
+ name: 'Carousel',
+ description:
+ 'Groups building blocks into slides, navigated vertically one at a time.',
+ },
+ DASHBOARD_BUILDING_BLOCKS_LOCATION,
+ CarouselBlock,
+ );
+
+ // 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
+ // 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
+ // such private type: its one child is held directly, with no intermediate
+ // pane (see `CollapsibleBlock`).
+ registerContainerType('tabs');
+ registerContainerType(TAB_TYPE);
+ registerContainerType('collapsible');
+ registerContainerType('carousel');
+ registerContainerType(SLIDE_TYPE);
}
diff --git a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts b/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
index 0f14285f35d..a0ed9d62060 100644
--- a/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
+++ b/superset-frontend/src/core/dashboard/resolveBuildingBlockView.ts
@@ -21,10 +21,12 @@ 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 (canvas/markdown/
- * echarts, see `registerBuiltInBuildingBlocks`) and extension-contributed
- * ones register here identically, through the same `views.registerView`
- * call.
+ * be renderable as a building block — built-in types (markdown/echarts/...,
+ * see `registerBuiltInBuildingBlocks`) 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
+ * rather than through this location.
*/
export const DASHBOARD_BUILDING_BLOCKS_LOCATION = 'dashboard.buildingBlocks';
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
index 5bfb026a34a..ab598741096 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx
@@ -55,17 +55,18 @@ test('building blocks is what you start on, and it lists what is registered', ()
expect(screen.getByTestId('palette-echarts')).toBeVisible();
});
-test('canvas is not offered in the palette, since nesting one is not an authored feature', () => {
+test('the root grid is not offered in the palette, since nesting one is not an authored feature', () => {
mount();
- // Canvas stays registered so the root node has something to render
- // through, but placing one is not offered.
+ // 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
+ // under either name to ever show up here in the first place.
expect(screen.queryByTestId('palette-canvas')).not.toBeInTheDocument();
- // With nothing left on it, the shelf it would have sat on does not render
- // either.
- expect(
- screen.queryByTestId('palette-shelf-structure'),
- ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('palette-grid')).not.toBeInTheDocument();
+ // The structure shelf still renders — it holds the genuine containers
+ // (tabs/collapsible/carousel), which the root is not one of.
+ expect(screen.getByTestId('palette-shelf-structure')).toBeInTheDocument();
});
test('clicking a block asks the page to place it', async () => {
@@ -163,7 +164,7 @@ const widthOf = () =>
test('the panel opens wide enough to edit a block in', () => {
mount();
- expect(widthOf()).toBe(500);
+ expect(widthOf()).toBe(350);
});
test('the handle resizes from the keyboard, so a drag is not the only way', () => {
@@ -172,7 +173,7 @@ test('the handle resizes from the keyboard, so a drag is not the only way', () =
handle.focus();
fireEvent.keyDown(handle, { key: 'ArrowRight' });
- expect(widthOf()).toBe(516);
+ expect(widthOf()).toBe(366);
fireEvent.keyDown(handle, { key: 'End' });
expect(widthOf()).toBe(800);
@@ -188,7 +189,7 @@ test('the handle reports the width it actually has', () => {
// control is lying about the only thing it does.
expect(screen.getByTestId('panel-resize')).toHaveAttribute(
'aria-valuenow',
- '500',
+ '350',
);
});
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
index 8c99dbcb4ee..ed292de8f60 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx
@@ -37,7 +37,7 @@ type PanelTab = 'blocks' | 'properties' | 'outline';
* narrow whatever they are given. The ceiling leaves a usable canvas on a
* small screen.
*/
-const DEFAULT_WIDTH = 500;
+const DEFAULT_WIDTH = 350;
const MIN_WIDTH = 280;
const MAX_WIDTH = 800;
/** How far one arrow press moves the edge. */
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
index 256a8cd0b2c..09f1e74c8e3 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx
@@ -333,7 +333,7 @@ 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: 'canvas' });
+ const section = provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
provider.addBuildingBlock(section, 0, { type: 'markdown' });
provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
selectRoot();
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
index f7f5a0018a0..725cc34be66 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx
@@ -18,18 +18,18 @@
*/
import { useMemo, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
+import type { views as viewsApi } from '@apache-superset/core';
import { t } from '@apache-superset/core/translation';
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 { views } from 'src/core/views';
+import { useViews } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
-import {
- CONTAINER_TYPE,
- isContainerType,
-} from 'src/core/dashboard/DashboardProvider';
+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.
*
@@ -60,16 +60,23 @@ export interface PaletteEntry {
readonly shelf: 'structure' | 'content';
}
-/** Everything registered as a building block, in the order it was registered. */
-export const paletteEntries = (): readonly PaletteEntry[] =>
- (views.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION) ?? [])
- .filter(view => view.id !== CONTAINER_TYPE)
- .map(view => ({
- type: view.id,
- label: view.name,
- description: view.description,
- shelf: isContainerType(view.id) ? 'structure' : 'content',
- }));
+/**
+ * Everything registered as a building block, 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
+ * list to begin with.
+ */
+export const paletteEntries = (
+ registered: readonly View[] | undefined,
+): readonly PaletteEntry[] =>
+ (registered ?? []).map(view => ({
+ type: view.id,
+ label: view.name,
+ description: view.description,
+ shelf: isContainerType(view.id) ? 'structure' : 'content',
+ }));
const matches = (entry: PaletteEntry, query: string): boolean => {
if (query === '') {
@@ -325,12 +332,19 @@ const Disclosure = ({
/**
* The building blocks, as things to place.
*
- * The list is the registry's — `views.getViews('dashboard.buildingBlocks')`,
- * the same call `BuildingBlockView` resolves a renderer through. Registering
- * a block makes it placeable and unregistering one removes it, with no list
- * here to keep in agreement — except canvas, which stays registered so the
- * root node has something to render through but is filtered out below, since
- * placing one is not an authored feature.
+ * 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
+ * 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
+ * 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
+ * at import time, well before anything here renders.
*/
export default function Palette({
onAdd,
@@ -340,7 +354,8 @@ export default function Palette({
const [query, setQuery] = useState('');
const [closed, setClosed] = useState<ReadonlySet<string>>(new Set());
- const entries = useMemo(paletteEntries, []);
+ const registered = useViews(DASHBOARD_BUILDING_BLOCKS_LOCATION);
+ const entries = useMemo(() => paletteEntries(registered), [registered]);
const found = entries.filter(entry => matches(entry, query));
const searching = query.trim() !== '';
const isOpen = (key: string): boolean => searching || !closed.has(key);
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
index 46c364fb4f4..0b757d4be3e 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
import userEvent from '@testing-library/user-event';
-import { act, render, screen } from 'spec/helpers/testing-library';
+import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import DashboardBuilderV2 from '.';
@@ -73,24 +73,55 @@ test('placing a block from the palette puts it on the dashboard and selects it',
expect(provider.getSelection()).toBe(children[0]);
});
+/** 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]]);
+ return {
+ types: [...data.keys()],
+ getData: (key: string) => data.get(key) ?? '',
+ setData: (key: string, value: string) => data.set(key, value),
+ dropEffect: '',
+ effectAllowed: '',
+ };
+};
+
+test('dropping a palette block 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.
+ fireEvent.drop(screen.getByTestId('empty-canvas'), {
+ dataTransfer: paletteTransfer('markdown'),
+ });
+
+ const children = provider.getRoot().children ?? [];
+ expect(children).toHaveLength(1);
+ expect(provider.getNode(children[0])?.type).toBe('markdown');
+});
+
test('a block placed while a container is selected goes inside it', async () => {
renderPage();
- // Canvas is not on the palette, but a container can still exist — placed
- // by the assistant, or carried over from before this UI stopped offering
- // it — and a block placed into a selected one still goes inside it.
- const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
- type: 'canvas',
+ // A 'tabs' block 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, {
+ type: 'tabs',
+ });
+ const paneId = provider.addBuildingBlock(tabsId, 0, {
+ type: 'tab',
+ props: { label: 'Overview' },
});
- act(() => provider.setSelection(sectionId));
+ act(() => provider.setSelection(paneId));
await userEvent.click(screen.getByTestId('palette-markdown'));
- // An author who has just selected a section and reaches for a block means
- // to put it in that section.
- expect(provider.getNode(sectionId)?.children).toEqual([
- provider.getSelection(),
- ]);
- expect(provider.getRoot().children).toEqual([sectionId]);
+ // An author who has just selected a pane and reaches for a block 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 () => {
diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
index 4ed2ce1d8ce..a2e6b375385 100644
--- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
+++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx
@@ -16,12 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { EmptyState, Flex } from '@superset-ui/core/components';
import { dashboard, useDashboardRevision } from 'src/core/dashboard';
import { provider } from 'src/core/dashboard/store';
-import { placeBlock } from 'src/core/dashboard/placement';
+import { PALETTE_MIME, placeBlock } from 'src/core/dashboard/placement';
import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
import DashboardHeader from './DashboardHeader';
import EditorPanel from './EditorPanel';
@@ -63,28 +64,19 @@ const EmptyCanvasWrapper = styled.div`
/**
* The dashboard with nothing on it, as something to aim at.
*
- * A dashed frame is what the rest of the app draws around a place a thing can
- * be dropped, and this is one — the palette drags land here. It is also the
- * only way to select the root on a blank canvas, so it answers a pointer and a
- * Tab the way anything clickable does: the frame firms up and the surface
- * lifts, rather than a dashed box that never reacts to being pressed.
+ * 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
+ * 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.
*/
const CanvasPlaceholder = styled(Flex)`
${({ theme }) => css`
width: 100%;
height: 100%;
- border: 2px dashed ${theme.colorBorder};
border-radius: ${theme.borderRadiusLG}px;
color: ${theme.colorTextTertiary};
cursor: pointer;
- transition:
- border-color ${theme.motionDurationMid},
- background-color ${theme.motionDurationMid};
-
- &:hover {
- border-color: ${theme.colorPrimaryBorderHover};
- background-color: ${theme.colorFillQuaternary};
- }
&:focus-visible {
outline: 2px solid ${theme.colorPrimaryBorder};
@@ -93,6 +85,26 @@ const CanvasPlaceholder = styled(Flex)`
`}
`;
+/**
+ * The same live drop indicator `RootGrid` draws once the root has a grid of
+ * its own to draw one onto (see its own `GridSurface` doc comment) — this is
+ * that same answer for the one moment there is no grid yet to ask. A blank
+ * dashboard is a single open target with only one possible outcome (the
+ * first block, full width, at the top) rather than a cell to resolve, so
+ * filling the whole placeholder is exactly right here — there is no
+ * "beside" or "below" anything yet to be more specific than "here" about,
+ * and no react-grid-layout instance underneath for this to coordinate with.
+ */
+const DropPreview = styled.div`
+ ${({ theme }) => css`
+ width: 100%;
+ height: 100%;
+ background-color: ${theme.colorPrimaryBg};
+ border: 2px dashed ${theme.colorPrimary};
+ border-radius: ${theme.borderRadiusLG}px;
+ `}
+`;
+
/**
* 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
@@ -119,6 +131,17 @@ export default function DashboardBuilderV2() {
const root = dashboard.getRoot();
const isEmpty = !root.children || root.children.length === 0;
+ // Whether a palette drag is currently over the empty-canvas placeholder —
+ // drives `DropPreview`, below. A counter rather than a plain boolean
+ // toggled on enter/leave: the placeholder isn't a single element, it's the
+ // wrapper plus whatever `EmptyState`/`DropPreview` renders inside it, and
+ // the pointer crossing from the wrapper onto one of those fires a `leave`
+ // on the outer element immediately followed by an `enter` on the inner one
+ // — a plain boolean would read that as leaving entirely and flicker the
+ // preview off for a frame. Only reaching zero really means "gone".
+ const [dragOverCount, setDragOverCount] = useState(0);
+ const isDragOver = dragOverCount > 0;
+
/**
* Places a block from the palette.
*
@@ -184,14 +207,49 @@ export default function DashboardBuilderV2() {
provider.setSelection(root.id);
}
}}
+ // The same drop target `RootGrid` offers once the root has
+ // at least one child — this stands in for it beforehand,
+ // 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
+ // this element cannot actually answer.
+ onDragEnter={event => {
+ if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+ setDragOverCount(count => count + 1);
+ }
+ }}
+ onDragLeave={event => {
+ if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+ setDragOverCount(count => Math.max(0, count - 1));
+ }
+ }}
+ onDragOver={event => {
+ if (event.dataTransfer.types.includes(PALETTE_MIME)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ }}
+ onDrop={event => {
+ const type = event.dataTransfer.getData(PALETTE_MIME);
+ setDragOverCount(0);
+ if (type !== '') {
+ event.preventDefault();
+ placeBlock(root.id, type);
+ }
+ }}
>
- <EmptyState
- image="empty-dashboard.svg"
- title={t('Start building')}
- description={t(
- 'Drag a building block from the panel, or ask the assistant for one.',
- )}
- />
+ {isDragOver ? (
+ <DropPreview data-test="empty-canvas-drop-preview" />
+ ) : (
+ <EmptyState
+ image="empty-dashboard.svg"
+ title={t('Start building')}
+ description={t(
+ 'Drag a building block from the panel, or ask the assistant for one.',
+ )}
+ />
+ )}
</CanvasPlaceholder>
</EmptyCanvasWrapper>
) : (