Compare commits

...
74 changed files with 12454 additions and 32 deletions
+3
View File
@@ -39,6 +39,9 @@ module.exports = {
// mapping @apache-superset/core to local package
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
// jsdom has no layout engine, so the real GridStack.init cannot run in a
// test at all (see spec/__mocks__/gridstackMock.ts for why).
'^gridstack$': '<rootDir>/spec/__mocks__/gridstackMock.ts',
},
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
modulePathIgnorePatterns: [
+17 -24
View File
@@ -102,6 +102,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.9.0",
"gridstack": "^13.0.2",
"immer": "^11.1.15",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -8567,9 +8568,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8587,9 +8585,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8607,9 +8602,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8627,9 +8619,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8647,9 +8636,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8667,9 +8653,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8687,9 +8670,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8707,9 +8687,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -23074,6 +23051,22 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/gridstack": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/gridstack/-/gridstack-13.0.2.tgz",
"integrity": "sha512-7uw3HxGgwL9XTifga3AcaV46d5zPue+tNsg1xNtbh29hG/Z+y882EkVD2b1D9uI8cW5aoHwevJrDPqBzJHkapw==",
"funding": [
{
"type": "paypal",
"url": "https://www.paypal.me/alaind831"
},
{
"type": "venmo",
"url": "https://www.venmo.com/adumesny"
}
],
"license": "MIT"
},
"node_modules/h3-js": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.2.1.tgz",
+1
View File
@@ -187,6 +187,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.9.0",
"gridstack": "^13.0.2",
"immer": "^11.1.15",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -22,6 +22,10 @@
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./dashboard": {
"types": "./lib/dashboard/index.d.ts",
"default": "./lib/dashboard/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
@@ -0,0 +1,242 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Dashboard building API for Superset extensions (prototype).
*
* Structural/layout operations on the "Dashboard v2" prototype dashboard: a
* flat, addressable tree of nodes (not nested JSX-in-JSON) so small,
* targeted edits are cheap — the shape an AI agent (most likely a `chat`
* extension) or any other extension calls to place, move, resize, and
* remove nodes. This is an early sketch of the design doc's platform-API
* section, not yet backed by persistence, real chart execution, or the
* `ChartPlugin`/building-block catalog — every method here is synchronous
* and in-memory.
*
* Deliberately granular, matching `sqlLab`'s own style (`getCurrentTab()`,
* `tab.getEditor()`, ...): there is no single "get everything" call. Start
* from {@link getRoot}, walk down via each node's `children` and
* {@link getNode}, and re-query after {@link onDidLayoutChange} fires.
*
* `dashboard` owns node placement and layout only. Block-instance content
* (props/style/dataBinding) is intentionally out of scope here — it belongs
* to a future `buildingBlocks` namespace mirroring this one.
*
* @example
* ```typescript
* import { dashboard } from '@apache-superset/core';
*
* const root = dashboard.getRoot();
* const id = dashboard.addBuildingBlock(root.id, 0, {
* type: 'text',
* props: { content: 'Hello dashboard' },
* });
* dashboard.updateLayout(id, { colSpan: 12 });
* ```
*/
import type { Event } from '../common';
/**
* Layout of a single node: a grid it lays out its own children in (only
* meaningful when the node is a container — ignored on leaf nodes), plus
* where the node itself sits within its *parent's* grid. A node can be both
* at once — a `canvas` nested inside another `canvas` both holds a grid for
* its own children and occupies cells in its parent's.
*
* There is no separate "flow" or "absolute" mode: a single-column grid with
* every child left at its default full-width span behaves like a plain
* top-to-bottom stack — it falls out of the same schema rather than
* requiring a different one.
*/
export interface LayoutProps {
// --- Container side: how this node arranges its own children. Ignored
// on a node with no `children`. ---
/** Number of equal fractional column tracks. Default: 24. */
columns?: number;
gap?: number;
/**
* Pixel height of one row track. Rows are never predivided from a fixed
* total — the grid creates as many as its content needs, each this tall,
* so a fixed unit keeps every child's height predictable without
* requiring a canvas of a predetermined total height.
*/
rowUnit?: number;
// --- Child side: where this node sits within its parent's grid. ---
/** How many of the parent's columns this node spans. Default: every column (full width). */
colSpan?: number;
/** How many row tracks this node spans. Default: 1. */
rowSpan?: number;
/** Explicit start column (1-based). Omit to let the grid auto-place this node in the next available cell. */
col?: number;
/** Explicit start row (1-based). Omit to let the grid auto-place this node in the next available cell. */
row?: number;
}
/**
* A single node in the dashboard tree. `canvas` and `text` are native
* layout primitives; any other `type` is a building-block registry key (a
* chart, metric tile, or extension-contributed block).
*
* `props`/`style` are inlined directly on the node for now. Once a
* `buildingBlocks` content namespace exists, block-type nodes will instead
* carry a `ref` into it — matching the design doc's split between dashboard
* layout and building-block content.
*/
export interface DashboardNode {
id: string;
/** Registry key used to pick a renderer — not part of this API's concern. */
type: string;
layout?: LayoutProps;
/**
* `canvas` nodes only — child node ids, in reading/DOM/tab order. This is
* independent of each child's visual position (its own `layout.col`/`row`)
* — moving a node within this array never changes where it's drawn, and
* repositioning a node on the canvas never changes this array.
*/
children?: string[];
/** Leaf/building-block nodes only — functional/content config. */
props?: Record<string, unknown>;
/** Leaf/building-block nodes only — visual customization. */
style?: Record<string, unknown>;
}
/** Everything needed to create a new node, passed to {@link addBuildingBlock}. */
export interface BuildingBlockSpec {
type: string;
layout?: LayoutProps;
props?: Record<string, unknown>;
style?: Record<string, unknown>;
}
/**
* Returns the root `canvas` node — the entry point for walking the tree.
* Its `children` array holds the top-level node ids.
*/
export declare function getRoot(): DashboardNode;
/**
* Returns a specific node, or undefined if `id` doesn't exist.
*/
export declare function getNode(id: string): DashboardNode | undefined;
/**
* Creates a new node and inserts it into a `canvas` parent's children at
* `index`.
*
* @param parentId Id of an existing `canvas` node.
* @param index Position among the parent's existing children; out-of-range
* values are clamped.
* @param spec The new node's type, layout, props, and style.
* @returns The new node's id.
*
* @example
* ```typescript
* const root = dashboard.getRoot();
* dashboard.addBuildingBlock(root.id, 0, {
* type: 'canvas',
* layout: { colSpan: 12, columns: 4, gap: 16 },
* });
* ```
*/
export declare function addBuildingBlock(
parentId: string,
index: number,
spec: BuildingBlockSpec,
): string;
/**
* Removes a node and its entire subtree (if it's a `canvas`), detaching it
* from its parent. No-op if `id` doesn't exist. Throws if `id` is the root.
*/
export declare function removeBuildingBlock(id: string): void;
/**
* Moves an existing node to a new `canvas` parent at `newIndex`, detaching
* it from wherever it currently sits. Throws if `newParentId` is `id` itself
* or one of its own descendants.
*/
export declare function moveBuildingBlock(
id: string,
newParentId: string,
newIndex: number,
): void;
/**
* Merges `layout` into a node's existing layout object.
*/
export declare function updateLayout(
id: string,
layout: Partial<LayoutProps>,
): void;
/**
* Shallow-merges `props` into a node's existing props — the content-side
* counterpart to {@link updateLayout}. Use this to edit an existing block
* in place (e.g. a chart's `dataBinding`/`echartsOptions`, or a markdown
* node's `content`) rather than removing and re-adding the node just to
* change what it renders, which loses its position, layout, and identity.
*/
export declare function updateProps(
id: string,
props: Record<string, unknown>,
): void;
/**
* Event fired after any structural or layout change. Carries no payload —
* re-query {@link getRoot}/{@link getNode} for whatever you need, since a
* single mutation (e.g. a move) can touch more than one node.
*/
export declare const onDidLayoutChange: Event<void>;
/**
* What an `echarts`-type building block queries. Deliberately generic (no
* `viz_type`): {@link fetchQueryData} always hits the same code path
* Superset falls back to when a form_data's `viz_type` has no registered
* ChartPlugin, so it works for any chart shape without per-viz-type
* integration.
*/
export interface DataBindingSpec {
datasetId: number;
/** Each entry is either a saved metric's exact name, or an ad hoc metric object. */
metrics: unknown[];
dimensions?: string[];
filters?: Record<string, unknown>[];
rowLimit?: number;
}
export type DataRow = Record<string, string | number | boolean | null>;
export interface QueryDataResult {
columns: string[];
rows: DataRow[];
}
/**
* Runs an ad hoc query against a dataset and returns plain tabular rows.
* Rejects with a descriptive error (e.g. an unknown column/metric name) if
* the query is invalid — callers that create `echarts` nodes should await
* this *before* calling {@link addBuildingBlock}, so a bad `dataBinding`
* surfaces as an immediate, correctable tool error instead of a node that
* silently fails to render later.
*/
export declare function fetchQueryData(
binding: DataBindingSpec,
): Promise<QueryDataResult>;
@@ -20,6 +20,7 @@ export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as dashboard from './dashboard';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
@@ -31,8 +31,8 @@ import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'explore'`, `'dashboard'`, `'dashboard_v2'` and `'dataset'` are the
* single-entity editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
@@ -41,6 +41,7 @@ import { Event } from '../common';
*/
export type Page =
| 'dashboard'
| 'dashboard_v2'
| 'dashboard_list'
| 'explore'
| 'chart_list'
@@ -58,7 +58,10 @@ export interface View {
*
* @param view The view descriptor (id and name).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param component The React component to render at that location.
* @param component The React component to render at that location. Most
* locations render it with no props; check the target location's own docs
* for whether it passes any (e.g. "dashboard.buildingBlocks" passes
* `{ nodeId }`).
* @returns A Disposable that unregisters the view when disposed.
*
* @example
@@ -73,7 +76,7 @@ export interface View {
export declare function registerView(
view: View,
location: string,
component: ComponentType,
component: ComponentType<any>,
): Disposable;
/**
@@ -86,6 +86,7 @@ import {
FundProjectionScreenOutlined,
FunctionOutlined,
HighlightOutlined,
HolderOutlined,
HomeOutlined,
InfoCircleOutlined,
InfoCircleFilled,
@@ -121,6 +122,7 @@ import {
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
RedoOutlined,
ReloadOutlined,
RightOutlined,
SaveOutlined,
@@ -137,6 +139,7 @@ import {
TagsOutlined,
TableOutlined,
LockOutlined,
UndoOutlined,
UnlockOutlined,
UploadOutlined,
UpOutlined,
@@ -245,6 +248,7 @@ const AntdIcons = {
GoogleOutlined,
GroupOutlined,
HighlightOutlined,
HolderOutlined,
HomeOutlined,
InfoCircleOutlined,
InfoCircleFilled,
@@ -281,6 +285,7 @@ const AntdIcons = {
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,
RedoOutlined,
RightOutlined,
SaveOutlined,
SearchOutlined,
@@ -296,6 +301,7 @@ const AntdIcons = {
TagsOutlined,
TableOutlined,
LockOutlined,
UndoOutlined,
UploadOutlined,
UnlockOutlined,
UpOutlined,
@@ -0,0 +1,181 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { gotoWithRetry } from '../helpers/navigation';
/** A `.grid-stack-item`'s own position/size, read from the `gs-x`/`gs-y`/`gs-w`/`gs-h` attributes GridStack itself writes — the ground truth of what's actually on screen, independent of anything the app's own React state claims. */
export interface GridItemAttrs {
x: number;
y: number;
w: number;
h: number;
}
/**
* Page object for the "Dashboard v2" prototype canvas (`/dashboard/v2/new/`)
* — a GridStack-backed root grid plus a palette of draggable building
* blocks. No backend persistence: each test starts from a fresh, empty
* in-memory dashboard.
*
* Two distinct drag mechanisms exist on this page and each test helper
* below is specific to one:
* - A palette item is native HTML5 `draggable`, read via `dataTransfer`.
* - An existing `.grid-stack-item` is GridStack's own pointer-based drag
* (no `dataTransfer` involved at all).
* Both are driven identically from Playwright's side (a real mouse
* press+move+release over the source element) — Chromium's own DnD
* machinery is what tells them apart, based on the source's own
* `draggable` attribute, not anything this page object does.
*/
export class DashboardV2Page {
private readonly page: Page;
private static readonly SELECTORS = {
EMPTY_CANVAS: '[data-test="empty-canvas"]',
EMPTY_CANVAS_PREVIEW: '[data-test="empty-canvas-drop-preview"]',
CANVAS: '[data-test="canvas"]',
GRID_CONTAINER: '[data-test="grid-container"]',
GRID_DROP_GHOST: '[data-test="grid-drop-ghost"]',
BUILDING_BLOCKS_TAB: 'text=Building blocks',
GRID_STACK_ITEM: '.grid-stack-item',
} as const;
constructor(page: Page) {
this.page = page;
}
async goto(): Promise<void> {
await gotoWithRetry(this.page, 'dashboard/v2/new/');
await this.page.waitForSelector(DashboardV2Page.SELECTORS.EMPTY_CANVAS);
}
palette(type: string): Locator {
return this.page.locator(`[data-test="palette-${type}"]`);
}
get emptyCanvas(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.EMPTY_CANVAS);
}
get emptyCanvasPreview(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.EMPTY_CANVAS_PREVIEW);
}
get canvas(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.CANVAS);
}
get gridContainer(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.GRID_CONTAINER);
}
get dropGhost(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.GRID_DROP_GHOST);
}
gridItems(): Locator {
return this.page.locator(DashboardV2Page.SELECTORS.GRID_STACK_ITEM);
}
/** Reads a `.grid-stack-item`'s own `gs-x`/`gs-y`/`gs-w`/`gs-h` attributes. */
async gridItemAttrs(item: Locator): Promise<GridItemAttrs> {
return item.evaluate(el => ({
x: Number(el.getAttribute('gs-x') ?? '0'),
y: Number(el.getAttribute('gs-y') ?? '0'),
w: Number(el.getAttribute('gs-w') ?? '1'),
h: Number(el.getAttribute('gs-h') ?? '1'),
}));
}
/**
* Places a block via a plain click (append full-width at the end) — no
* drag involved, the fastest way to get something onto the canvas for a
* test that isn't itself exercising placement.
*/
async placeBlockByClick(type: string): Promise<void> {
await this.palette(type).click();
await this.gridItems().first().waitFor();
}
/**
* Switches back to the "Building blocks" palette tab. Placing (or
* selecting) a block switches the editor panel to Properties, so a test
* that places one block via `placeBlockByClick` and then wants to drag a
* second one from the palette needs this in between.
*/
async showPalette(): Promise<void> {
await this.page
.locator(DashboardV2Page.SELECTORS.BUILDING_BLOCKS_TAB)
.click();
}
/**
* Presses down on a palette item and moves the pointer to `(x, y)` in
* viewport coordinates, WITHOUT releasing — deliberately not
* `page.dragAndDrop`, which only performs one atomic down-move-up and
* gives no chance to inspect a live preview mid-gesture. The caller is
* responsible for eventually calling `page.mouse.up()`, and may call
* `page.mouse.move(...)` again first to inspect an intermediate hover
* position (see `moveTo` below).
*/
async startPaletteDrag(type: string, x: number, y: number): Promise<void> {
const box = await this.palette(type).boundingBox();
if (!box) throw new Error(`Palette item "${type}" has no bounding box`);
await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await this.page.mouse.down();
// A few small steps first: GridStack's own drag threshold (and some
// native DnD implementations) only recognize a drag once the pointer
// has actually moved a few pixels, not on the mousedown alone.
await this.page.mouse.move(
box.x + box.width / 2 + 10,
box.y + box.height / 2 + 10,
{ steps: 5 },
);
await this.moveTo(x, y);
}
/**
* Presses down on an existing grid item — near its own header band, well
* clear of the corner resize handles and the top-right remove button —
* and moves the pointer to `(x, y)`, WITHOUT releasing. This is
* GridStack's own pointer-based drag, not native HTML5 drag; no
* `dataTransfer` is involved.
*/
async startItemDrag(item: Locator, x: number, y: number): Promise<void> {
const box = await item.boundingBox();
if (!box) throw new Error('Grid item has no bounding box');
const grabX = box.x + box.width / 2;
const grabY = box.y + 12;
await this.page.mouse.move(grabX, grabY);
await this.page.mouse.down();
await this.page.mouse.move(grabX + 15, grabY + 15, { steps: 5 });
await this.moveTo(x, y);
}
/** Continues an in-progress drag (started via `startPaletteDrag`/`startItemDrag`) to a new point, in several steps so intermediate `dragover`/`mousemove` events actually fire. */
async moveTo(x: number, y: number): Promise<void> {
await this.page.mouse.move(x, y, { steps: 15 });
}
/** Releases the mouse button, ending whichever drag is in progress. */
async release(): Promise<void> {
await this.page.mouse.up();
}
}
@@ -102,6 +102,22 @@ Once an experimental test has proven stable (consistent CI passes over time):
- Includes: Delete dataset test with API-based test data
- Supporting infrastructure: API helpers, Modal components, page objects
### Dashboard v2 (GridStack canvas) Tests
- **`dashboard-v2/empty-canvas.spec.ts`**, **`reposition-and-resize.spec.ts`**,
**`split.spec.ts`**, **`layout-regressions.spec.ts`** - E2E coverage for the
GridStack-backed root grid at `/dashboard/v2/new/`
- Status: Infrastructure complete, validating stability
- Covers: live drop-preview tracking/sizing/clearing, drag-to-reposition
and resize persistence, the left/right split gesture (from the palette
and by repositioning an existing block), and single-scroll-owner layout
- Every case here was a real bug found only by driving GridStack in a
real browser during development — none are reachable from the
jsdom-based unit suite (`RootGrid.test.tsx`, `gridPacking.test.ts`),
since jsdom has no layout engine and never runs GridStack's own DOM/CSS
logic at all
- Supporting infrastructure: `pages/DashboardV2Page.ts`
## Infrastructure Location
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
@@ -0,0 +1,133 @@
/**
* 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.
*/
/**
* Dashboard v2 prototype — dropping a palette block onto a blank canvas.
*
* All three cases here were live bugs found only by driving a real browser,
* not by any jsdom-based unit test: jsdom has no layout engine, so
* `getBoundingClientRect()` always returns zeros there unless a test
* manually stubs it — which proves the *logic* reads coordinates correctly,
* never that the *browser* actually measures and lays things out the way
* the code assumes. These specifically need a real one:
*
* 1. The live preview once always filled the entire canvas regardless of
* where the cursor was (`width: 100%; height: 100%`) — fixed to be a
* cursor-following, capped-size box.
* 2. The preview once stayed on screen if a drag ended without a `drop` or
* `dragleave` (e.g. released past the browser window's own edge) — a
* `dragend` backstop was added.
* 3. A drop always landed at the top-left regardless of where it was
* actually released, because the position-aware commit
* (`placeBlockAt`) was wired up after the ghost preview was, not with it.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* playwright/tests/experimental/dashboard-v2/empty-canvas.spec.ts
*/
import { test, expect } from '@playwright/test';
import { DashboardV2Page } from '../../../pages/DashboardV2Page';
test('the live preview follows the cursor and stays capped, not full-canvas', async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
const canvasBox = await dashboard.emptyCanvas.boundingBox();
if (!canvasBox) throw new Error('empty canvas has no bounding box');
await dashboard.startPaletteDrag(
'markdown',
canvasBox.x + canvasBox.width * 0.15,
canvasBox.y + canvasBox.height * 0.15,
);
const topLeftBox = await dashboard.emptyCanvasPreview.boundingBox();
if (!topLeftBox) throw new Error('preview did not appear near top-left');
// Regression: this used to be `width: 100%; height: 100%` of the whole
// canvas, so it never actually moved with the cursor at all.
expect(topLeftBox.width).toBeLessThan(canvasBox.width * 0.9);
expect(topLeftBox.height).toBeLessThan(canvasBox.height * 0.9);
await dashboard.moveTo(
canvasBox.x + canvasBox.width * 0.75,
canvasBox.y + canvasBox.height * 0.75,
);
const bottomRightBox = await dashboard.emptyCanvasPreview.boundingBox();
if (!bottomRightBox) throw new Error('preview disappeared mid-drag');
// The same box, now positioned near the opposite corner — proves it is
// actually tracking the cursor, not just rendering somewhere fixed.
expect(bottomRightBox.x).toBeGreaterThan(topLeftBox.x + 100);
expect(bottomRightBox.y).toBeGreaterThan(topLeftBox.y + 100);
await dashboard.release();
});
test('the preview clears if the drag ends without a drop (released off-canvas)', async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
const canvasBox = await dashboard.emptyCanvas.boundingBox();
if (!canvasBox) throw new Error('empty canvas has no bounding box');
await dashboard.startPaletteDrag(
'markdown',
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2,
);
await expect(dashboard.emptyCanvasPreview).toBeVisible();
// Move well outside the canvas (into the page's own margin/toolbar area)
// and release there — no `drop`, no `dragleave` back into the canvas.
await dashboard.moveTo(canvasBox.x - 40, 5);
await dashboard.release();
await expect(dashboard.emptyCanvasPreview).not.toBeVisible();
// Nothing should have been placed either — this was a cancelled drag.
await expect(dashboard.gridItems()).toHaveCount(0);
});
test('a drop lands where it was actually released, not always at the top', async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
const canvasBox = await dashboard.emptyCanvas.boundingBox();
if (!canvasBox) throw new Error('empty canvas has no bounding box');
const dropX = canvasBox.x + canvasBox.width * 0.6;
const dropY = canvasBox.y + canvasBox.height * 0.6;
await dashboard.startPaletteDrag('markdown', dropX, dropY);
await dashboard.release();
const item = dashboard.gridItems().first();
await expect(item).toBeVisible();
const { y } = await dashboard.gridItemAttrs(item);
// Regression: this used to always be col 0 / row 0 (`placeBlock`'s
// append-at-the-end path) regardless of where the drop actually
// happened. Dropping well below the canvas's own top edge should land
// at a non-zero row.
expect(y).toBeGreaterThan(0);
});
@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Dashboard v2 prototype — scroll ownership for a canvas taller than the
* viewport.
*
* `RootGrid`'s own surface (`data-container-id="root"`) and the page-level
* `Canvas` (`data-test="canvas"`) both used to be independent
* `overflow: auto` boxes nested inside one another — two scroll containers
* fighting over the same mouse-wheel input, which reads as broken
* scrolling rather than "the canvas is tall, scroll it". This is a real
* CSS cascade/box-model question with no jsdom equivalent: jsdom never
* actually computes `scrollHeight`/`clientHeight`/`overflow` against a real
* layout, so a unit test asserting this would only ever assert whatever
* value a test manually stubbed in.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* playwright/tests/experimental/dashboard-v2/layout-regressions.spec.ts
*/
import { test, expect } from '@playwright/test';
import { DashboardV2Page } from '../../../pages/DashboardV2Page';
test('the canvas is the single scroll owner when content overflows the viewport', async ({
page,
}) => {
await page.setViewportSize({ width: 1400, height: 600 });
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
// Enough tall blocks to guarantee the canvas overflows a 600px viewport.
for (let i = 0; i < 5; i += 1) {
await dashboard.placeBlockByClick('echarts');
await dashboard.showPalette();
}
const canvasMetrics = await dashboard.canvas.evaluate(el => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
overflowY: getComputedStyle(el).overflowY,
}));
expect(canvasMetrics.overflowY).toBe('auto');
expect(canvasMetrics.scrollHeight).toBeGreaterThan(
canvasMetrics.clientHeight,
);
const rootMetrics = await page
.locator('[data-container-id="root"]')
.evaluate(el => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
overflowY: getComputedStyle(el).overflowY,
}));
// Regression: this used to also be `overflow-y: auto` with its own
// `scrollHeight > clientHeight`, giving it a second, independent
// scrollbar of its own.
expect(rootMetrics.overflowY).toBe('visible');
});
@@ -0,0 +1,163 @@
/**
* 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.
*/
/**
* Dashboard v2 prototype — dragging/resizing a block already on the grid.
*
* Every case here was a real bug found only by driving GridStack in a real
* browser; none of them were (or could be) caught by the jsdom-mocked
* `RootGrid.test.tsx` suite, since jsdom never actually runs GridStack's own
* DOM/CSS logic:
*
* 1. GridStack's own `auto: true` silently claimed every widget present at
* mount with no `id`, before this app's own code ever got to register
* them — every drag committed nothing and visibly reverted on drop.
* 2. An over-broad `cancel` selector matched the grid's own ancestor
* marker on every press, vetoing every drag unconditionally (resize
* alone worked, since it's a separate code path that never checks
* `cancel`).
* 3. A CSS `min-height: 100%` (instead of `height: 100%`) on the grid's own
* surface broke the percentage-height chain for `.grid-stack`'s own
* floor, shrinking its real drop target to fit only existing content —
* any empty space beyond sparse content silently fell back to a
* different, ghost-less, always-full-width drop path.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* playwright/tests/experimental/dashboard-v2/reposition-and-resize.spec.ts
*/
import { test, expect } from '@playwright/test';
import { DashboardV2Page } from '../../../pages/DashboardV2Page';
test('dragging an existing block to a new position persists', async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
await dashboard.placeBlockByClick('markdown');
const item = dashboard.gridItems().first();
const before = await dashboard.gridItemAttrs(item);
const box = await item.boundingBox();
if (!box) throw new Error('grid item has no bounding box');
await dashboard.startItemDrag(
item,
box.x + box.width / 2 + 200,
box.y + box.height / 2 + 300,
);
await dashboard.release();
const after = await dashboard.gridItemAttrs(item);
// Regression: this used to always equal `before` — the drag visibly
// moved the block, then it snapped straight back on release.
expect(after.y).toBeGreaterThan(before.y);
// Survives an unrelated re-render (switching palette tabs), not just
// looking right until something else touches the store.
await dashboard.showPalette();
const afterRerender = await dashboard.gridItemAttrs(item);
expect(afterRerender).toEqual(after);
});
test('resizing an existing block from a corner persists', async ({ page }) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
await dashboard.placeBlockByClick('markdown');
const item = dashboard.gridItems().first();
const before = await dashboard.gridItemAttrs(item);
// `placeBlockByClick` appends full-width (`DEFAULT_COLUMNS`, the grid's
// own max) — there is no wider to resize to, so the corner picked here
// has to be one that can still demonstrate a *width* change too, not
// just height. `sw` (bottom-left) shrinks width while growing height;
// `se` alone couldn't have grown width past the columns it already
// spans.
expect(before.w).toBeGreaterThan(1);
// The resize handles are hidden (`ui-resizable-autohide`) until the
// pointer is actually over the item.
await item.hover();
const handle = item.locator('.ui-resizable-sw');
await handle.waitFor({ state: 'visible' });
const handleBox = await handle.boundingBox();
if (!handleBox) throw new Error('resize handle has no bounding box');
// `sw` anchors the top-right corner: moving the cursor toward the
// block's own interior (right, since this is its bottom-*left* corner)
// shrinks the width; moving it down grows the height.
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 15, startY + 15, { steps: 5 });
await page.mouse.move(startX + 200, startY + 150, { steps: 15 });
await page.mouse.up();
const after = await dashboard.gridItemAttrs(item);
expect(after.w).toBeLessThan(before.w);
expect(after.h).toBeGreaterThan(before.h);
await dashboard.showPalette();
const afterRerender = await dashboard.gridItemAttrs(item);
expect(afterRerender).toEqual(after);
});
test('dropping into open space below sparse content shows a live preview and lands at less than full width', async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
// Place one block near the top-left, leaving a large empty area below.
const canvasBox = await dashboard.emptyCanvas.boundingBox();
if (!canvasBox) throw new Error('empty canvas has no bounding box');
await dashboard.startPaletteDrag(
'markdown',
canvasBox.x + 80,
canvasBox.y + 60,
);
await dashboard.release();
await dashboard.showPalette();
const gridBox = await dashboard.gridContainer.boundingBox();
if (!gridBox) throw new Error('grid container has no bounding box');
// Hover far below the first block, still within the canvas.
await dashboard.startPaletteDrag(
'markdown',
gridBox.x + 80,
gridBox.y + gridBox.height - 80,
);
await expect(dashboard.dropGhost).toBeVisible();
const ghostBox = await dashboard.dropGhost.boundingBox();
if (!ghostBox) throw new Error('ghost did not appear');
// Regression: `.grid-stack`'s own real box used to shrink to fit only
// the first block, so this point fell outside it entirely and no ghost
// ever appeared here.
expect(ghostBox.width).toBeLessThan(gridBox.width * 0.9);
await dashboard.release();
await expect(dashboard.gridItems()).toHaveCount(2);
const second = await dashboard.gridItemAttrs(dashboard.gridItems().nth(1));
// Regression: the same fallback path that skipped the ghost also always
// appended a full-width block regardless of where the drop happened.
expect(second.w).toBeLessThan(24);
});
@@ -0,0 +1,122 @@
/**
* 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.
*/
/**
* Dashboard v2 prototype — dropping/dragging a block onto the left/right
* half of an *existing* block splits it: the existing block shrinks to the
* other half, and the dragged/dropped block takes the half it landed on.
*
* The live-preview assertions here (the existing block visibly shrinking
* mid-hover, not a placeholder box standing in for it) are the one piece of
* this feature that is fundamentally unverifiable outside a real browser —
* it depends on `useGridStack`'s sync effect actually diffing a
* temporarily-substituted rect against GridStack's live DOM node and
* calling `update()` on it, mid-gesture, before anything is committed.
*
* Lives under tests/experimental/ until proven stable in CI; run with:
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
* playwright/tests/experimental/dashboard-v2/split.spec.ts
*/
import { test, expect } from '@playwright/test';
import { DashboardV2Page } from '../../../pages/DashboardV2Page';
test("a palette drop on an existing block's left half splits it, live", async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
await dashboard.placeBlockByClick('markdown');
await dashboard.showPalette();
const target = dashboard.gridItems().first();
const targetBoxBefore = await target.boundingBox();
if (!targetBoxBefore) throw new Error('target block has no bounding box');
// Hover the left quarter of the target, vertically centered on it (the
// middle band — not near its top/bottom edge, which means "insert a
// full-width row" instead of "split").
await dashboard.startPaletteDrag(
'markdown',
targetBoxBefore.x + targetBoxBefore.width * 0.15,
targetBoxBefore.y + targetBoxBefore.height / 2,
);
// The REAL target block visibly shrinks to the right half, live — not a
// second placeholder box drawn over it.
const targetBoxDuring = await target.boundingBox();
if (!targetBoxDuring) throw new Error('target block disappeared mid-hover');
expect(targetBoxDuring.width).toBeLessThan(targetBoxBefore.width * 0.7);
expect(targetBoxDuring.x).toBeGreaterThan(targetBoxBefore.x);
// The new block's own ghost occupies the complementary (left) half.
const ghostBox = await dashboard.dropGhost.boundingBox();
if (!ghostBox) throw new Error('drop ghost did not appear');
expect(ghostBox.x).toBeLessThan(targetBoxDuring.x);
expect(ghostBox.width).toBeLessThan(targetBoxBefore.width * 0.7);
await dashboard.release();
await expect(dashboard.gridItems()).toHaveCount(2);
const items = dashboard.gridItems();
const first = await dashboard.gridItemAttrs(items.nth(0));
const second = await dashboard.gridItemAttrs(items.nth(1));
// Both halves, side by side, on the same row, neither full width.
expect(first.y).toBe(second.y);
expect(first.w).toBeLessThan(24);
expect(second.w).toBeLessThan(24);
expect(first.x).not.toBe(second.x);
});
test("dragging an existing block onto another block's half splits it the same way", async ({
page,
}) => {
const dashboard = new DashboardV2Page(page);
await dashboard.goto();
// Two full-width blocks, one above the other.
await dashboard.placeBlockByClick('markdown');
await dashboard.showPalette();
await dashboard.placeBlockByClick('echarts');
await dashboard.showPalette();
const items = dashboard.gridItems();
const target = items.nth(0); // markdown, on top
const dragged = items.nth(1); // echarts, below it
const targetBox = await target.boundingBox();
const draggedBox = await dragged.boundingBox();
if (!targetBox || !draggedBox) {
throw new Error('one of the two blocks has no bounding box');
}
// Drag the second block up onto the first one's left half.
await dashboard.startItemDrag(
dragged,
targetBox.x + targetBox.width * 0.15,
targetBox.y + targetBox.height / 2,
);
await dashboard.release();
const firstAttrs = await dashboard.gridItemAttrs(items.nth(0));
const secondAttrs = await dashboard.gridItemAttrs(items.nth(1));
expect(firstAttrs.y).toBe(secondAttrs.y);
expect(firstAttrs.w).toBeLessThan(24);
expect(secondAttrs.w).toBeLessThan(24);
expect(firstAttrs.x).not.toBe(secondAttrs.x);
});
@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* A global `moduleNameMapper` stand-in for the real `gridstack` package
* (wired in `jest.config.js`) — jsdom has no layout engine, so the real
* `GridStack.init` cannot run in a test at all (it measures the container).
* Every test that mounts `RootGrid` goes through this, not just
* `RootGrid.test.tsx` itself.
*
* Mimics only the surface `useGridStack.ts` actually calls, plus a
* `__trigger` escape hatch a test uses to fire the gesture-end callbacks a
* real drag/resize would otherwise fire — there is no synthetic drag in
* jsdom to produce those for us.
*/
export interface MockGridStackNode {
id?: string;
x?: number;
y?: number;
w?: number;
h?: number;
}
export interface MockGridItemHTMLElement extends HTMLElement {
gridstackNode?: MockGridStackNode;
}
type Handler = (event: Event, el: MockGridItemHTMLElement) => void;
export class MockGridStack {
static instances: MockGridStack[] = [];
options: Record<string, unknown>;
container: HTMLElement;
private handlers: Record<string, Handler[]> = {};
private items: MockGridItemHTMLElement[] = [];
static init(
options: Record<string, unknown>,
container: HTMLElement,
): MockGridStack {
const grid = new MockGridStack(options, container);
MockGridStack.instances.push(grid);
return grid;
}
constructor(options: Record<string, unknown>, container: HTMLElement) {
this.options = options;
this.container = container;
}
on = jest.fn((event: string, cb: Handler) => {
(this.handlers[event] ||= []).push(cb);
});
makeWidget = jest.fn(
(el: MockGridItemHTMLElement, node: MockGridStackNode) => {
el.gridstackNode = { ...node };
if (!this.items.includes(el)) this.items.push(el);
return el;
},
);
update = jest.fn(
(el: MockGridItemHTMLElement, node: Partial<MockGridStackNode>) => {
el.gridstackNode = { ...el.gridstackNode, ...node };
},
);
removeWidget = jest.fn((el: MockGridItemHTMLElement) => {
this.items = this.items.filter(item => item !== el);
});
batchUpdate = jest.fn();
column = jest.fn((count: number) => {
this.options.column = count;
});
margin = jest.fn();
cellHeight = jest.fn();
getGridItems = jest.fn((): MockGridItemHTMLElement[] => this.items);
destroy = jest.fn();
/** Fires every callback registered for `event` via `on`, as if `el` had just finished that gesture. */
__trigger(event: string, el: MockGridItemHTMLElement): void {
(this.handlers[event] ?? []).forEach(cb => cb({} as Event, el));
}
}
export function __resetGridStackMock(): void {
MockGridStack.instances = [];
}
/** The instance the most recent `RootGrid` mount created — there is exactly one per mounted grid. */
export function __getLastGridStackInstance(): MockGridStack | undefined {
return MockGridStack.instances[MockGridStack.instances.length - 1];
}
export const GridStack = MockGridStack;
@@ -0,0 +1,166 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from './DashboardProvider';
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
import BuildingBlockView from './BuildingBlockView';
const provider = DashboardProvider.getInstance();
beforeAll(() => {
registerBuiltInBuildingBlocks();
});
beforeEach(() => {
provider.reset();
});
const withBlock = () => {
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 0, {
type: 'metric-tile',
props: { label: 'Quarterly notes' },
});
render(<BuildingBlockView nodeId={id} />);
return { rootId, id };
};
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 "Metric Tile" in the other.
expect(screen.getByTestId(`block-title-${id}`)).toHaveTextContent(
'Quarterly notes',
);
});
test('the delete control does not have to be found first', () => {
const { id } = withBlock();
// It used to appear only on hover, which is a control you have to already
// know is there. `toBeVisible` fails on the opacity that hid it.
expect(screen.getByTestId(`block-remove-${id}`)).toBeVisible();
});
test('removing a block is offered as a bin, not as a cross', () => {
const { id } = withBlock();
// A cross on a card is the gesture for dismissing the card — closing it,
// putting it away, getting it off screen. This takes the block off the
// dashboard, and the bin is what says that everywhere else in the app.
expect(
screen.getByTestId(`block-remove-${id}`).querySelector('.anticon-delete'),
).toBeInTheDocument();
});
test('the root carries no header of its own', () => {
const rootId = provider.getRoot().id;
render(<BuildingBlockView nodeId={rootId} />);
// The root is the dashboard rather than something on it: a header there
// would label it "Canvas" and offer a delete the provider refuses.
expect(
screen.queryByTestId(`block-header-${rootId}`),
).not.toBeInTheDocument();
expect(
screen.queryByTestId(`block-remove-${rootId}`),
).not.toBeInTheDocument();
});
test("a block's name reads as its title, not as a caption on it", () => {
const { id } = withBlock();
// Set in the secondary colour at the small size, it read as an annotation
// hanging above the block rather than as the name of the thing below it —
// which is what it is, and the first thing anyone scanning the canvas uses
// to tell one block from the next.
const title = screen.getByTestId(`block-title-${id}`);
expect(title).toHaveStyle({ color: 'rgba(0, 0, 0, 0.88)' });
// Compared rather than pinned: `fontWeightStrong` is a theme token, and it
// does not resolve to the same number here as it does in the app. Asserting
// the literal would be asserting the test theme's value, which is not the
// one that ships.
expect(Number(getComputedStyle(title).fontWeight)).toBeGreaterThan(400);
});
/** The element a node draws itself as — the card, for a block that has one. */
const frameOf = (id: string) =>
document.querySelector(`[data-node-id="${id}"]`) as HTMLElement;
test('a block hides what it is drawn over, name and all', () => {
const { rootId, id } = withBlock();
// A free canvas lets blocks overlap, and only the leaf's own box was ever
// opaque — so a block raised to the front still showed whatever sat behind
// it through the strip carrying its name, and two overlapping blocks
// rendered their names on top of each other.
expect(frameOf(id)).toHaveStyle({ backgroundColor: '#FFFFFF' });
// The root is the canvas everything is arranged on, not a card on it.
render(<BuildingBlockView nodeId={rootId} />);
expect(frameOf(rootId)).not.toHaveStyle({ backgroundColor: '#FFFFFF' });
});
test('a block is one card, with its name inside the frame rather than above it', () => {
const { id } = withBlock();
// The frame was drawn by the leaf, which begins below the header — so a
// card's top edge ran between a block's name and its contents, and the name
// read as a caption floating over a separate box rather than as the head of
// the card it belongs to. Drawn once, around both, it is one card.
const frame = frameOf(id);
expect(frame.style.border).toMatch(/^1px solid /);
expect(frame.style.borderRadius).not.toBe('');
// Nothing can spill past the corners the frame rounds.
expect(frame).toHaveStyle({ overflow: 'hidden' });
// And the band no longer paints a surface of its own over the one it is on:
// two backgrounds meeting at the header's edge is the seam this removes.
expect(screen.getByTestId(`block-header-${id}`).style.backgroundColor).toBe(
'',
);
});
test('a leaf block no longer frames itself, so there is one border and not two', () => {
const { id } = withBlock();
const leaf = screen.getByTestId(`block-content-${id}`)
.firstElementChild as HTMLElement;
expect(leaf.style.border).toBe('');
expect(leaf.style.borderRadius).toBe('');
expect(leaf.style.backgroundColor).toBe('');
});
test('the header takes its height out of the block, not out of the canvas', () => {
const { rootId, id } = withBlock();
// A leaf block resolves `height: 100%` against this box — a chart measures
// the result to size its canvas — so the band above it has to come out of
// the height rather than be added to it, or every block overflows its cell
// by exactly the header.
expect(screen.getByTestId(`block-content-${id}`).style.height).toMatch(
/^calc\(100% - \d+px\)$/,
);
// The root has no header to subtract.
render(<BuildingBlockView nodeId={rootId} />);
expect(screen.getByTestId(`block-content-${rootId}`)).toHaveStyle({
height: '100%',
});
});
@@ -0,0 +1,418 @@
/**
* 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 { forwardRef, type HTMLAttributes } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { ActionButton, Flex, Typography } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { ErrorBoundary } from 'src/components';
import { provider, useDashboardRevision } from './store';
import { resolveBuildingBlockView } from './resolveBuildingBlockView';
import { blockLabel } from './blockLabel';
import { blockHeaderControl } from './blockHeaderControl';
import RootGrid from './RootGrid';
function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) {
const theme = useTheme();
const node = provider.getNode(nodeId);
if (!node) return null;
return (
<Flex
vertical
align="center"
justify="center"
style={{
width: '100%',
height: '100%',
border: `1px dashed ${theme.colorBorderSecondary}`,
borderRadius: theme.borderRadiusLG,
padding: theme.padding,
backgroundColor: theme.colorFillQuaternary,
}}
>
<Typography.Text type="secondary">
{t('Unsupported block type:')} {node.type}
</Typography.Text>
</Flex>
);
}
/**
* A block's name, and what can be done to the block.
*
* Carries no surface of its own and no rule under it. The card behind this is
* opaque and unbroken, so a second background here would only draw a seam
* across it a hand's width below the top edge — the block would read as a
* strip and a box rather than as one card with a name on it.
*/
const BlockHeader = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeightSM}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 grid
* drag.
*
* `data-block-header-control` is the other half of that second one —
* `RootGrid` names it in the grid's own drag-cancel selector, which matches
* it up the ancestors, so carrying it here covers the control inside.
*/
const HeaderControlSlot = styled.span`
display: flex;
flex: 0 0 auto;
`;
/**
* What the remove control is wrapped in, and why it is wrapped at all.
*
* The control itself is `ActionButton` — the shared component for an icon
* action carried on a surface that is already something else, and the one the
* dashboard list uses for its own Delete. It takes an `onClick` with no event,
* so the two gestures this sits inside are stopped here instead: a click on
* the bin must remove rather than select the block it is drawn on, and a
* pointer down on it must not start a grid drag.
*
* `data-block-remove` is the other half of that second one — `RootGrid`
* names it in the grid's own drag-cancel selector, which matches it up the
* ancestors, so carrying it here covers the button inside.
*/
const RemoveSlot = styled.span`
display: flex;
flex: 0 0 auto;
`;
interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> {
nodeId: string;
}
/**
* The single entry point for rendering a dashboard node. A node's `type` is
* resolved against `dashboard.buildingBlocks` views — built-in types
* (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`.
*
* 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` positions/sizes each child by wrapping it in a grid item element
* of its own and passing this component an explicit `style={{width:'100%',
* height:'100%'}}` to fill it — the same convention `flowContent.tsx`'s
* `FlowItem` already used for a flowed block, which is why this accepts
* `...rest` (covering that `style` prop, among others) and forwards a `ref`
* rather than each block doing that itself. That's deliberate: a block,
* built-in or extension-contributed, should only ever need to fill 100% of
* whatever box it's given, not know it's sitting in a grid at all, let alone
* that the grid is draggable/resizable. Before this existed, every block
* (and every third-party extension) had to resolve its own placement, which
* meant reimplementing (and risking drifting from) the same parent-lookup
* logic — see `dashboard-insights`'s own `getParentDirection` for what that
* duplication looked like from outside the host bundle, where
* `DashboardProvider` isn't importable at all.
* `children` (when present) is a block's own extra content layered on top of
* it rather than replacing it — see `flowContent.tsx`'s `ResizeGrip` for the
* one built-in use of this.
*
* Wrapped per-node in an ErrorBoundary: a block's content (e.g. an
* AI-authored `echartsOptions` that turns out malformed at render/effect
* time) is untrusted input the same way a dataset value is, and one bad
* block must not unmount the rest of the dashboard along with it.
*/
const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>(
function BuildingBlockView({ nodeId, children, ...rest }, ref) {
useDashboardRevision();
const theme = useTheme();
const node = provider.getNode(nodeId);
if (!node) return null;
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 "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;
return (
<div
ref={ref}
{...rest}
// Where a node is on screen, for the panels that reach into the
// canvas from outside it — the Outline scrolls to the block it just
// selected by finding it here. Set after the spread so a parent
// renderer cannot displace a node's own identity.
data-node-id={nodeId}
// Every block is a thing an author selects, so every block is a
// control — announced as one, reachable by Tab, and answering the
// keys a control answers. The outline offers the same selection in a
// tree, but a block you can point at and not reach from the keyboard
// is still a block half the people using this cannot select.
// A real `button` is not available: this element carries its own
// ref and an injected `style` (see this component's own doc
// comment), and a block's content is interactive in its own right —
// a chart, a table — which a `button` may not contain.
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={node.type}
// The propagation stop is what makes a click on a block inside a
// container select the block rather than the container holding it —
// both are nodes and both render through here, so the innermost one
// has to claim the gesture.
onClick={event => {
event.stopPropagation();
provider.setSelection(nodeId);
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
provider.setSelection(nodeId);
}
}}
style={{
...rest.style,
// A block's contents are positioned against this element.
// A caller that already set its own `position` (nothing does
// today) is kept; `relative` only fills the gap when it did not.
position: rest.style?.position ?? 'relative',
// Sized the same way every other block already is — everything
// that is not the root gets its width/height from whatever
// rendered it (`RootGrid`'s grid item wrapper, or `flowContent.tsx`'s
// `FlowItem`), passed in through `style` and captured above via
// `...rest.style`. The root gets no such caller — it is rendered
// directly, with no props — so without this it has no width or
// height at all and shrinks to whatever its own content happens to
// be — which is exactly one block tall, with nothing below it to
// 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.
//
// This used to be each leaf block's own — every one of them opened
// with the same background, border and radius — and a leaf begins
// below the header, so the card's top edge ran between a block's
// name and its contents. The name sat outside the box it names,
// reading as a caption dropped over a separate card. Drawn here it
// encloses both, which is also the only place it can be drawn from:
// whether a node has a header at all is this component's to know,
// not the leaf's.
//
// Opaque for the same reason it is one card: on a free canvas
// blocks overlap, and anything a block does not paint is a window
// 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: 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. Moot on the
// root, which rounds nothing.
overflow: isRoot ? undefined : 'hidden',
// 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.
//
// 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.
The name comes from `blockLabel`, the same call the Outline names
a row by, so a block is not "Sales by Territory" in one place and
"ECharts" in the other. A chart's name is authored in its ECharts
option and ChartBlock stops ECharts drawing it, so it appears here
once instead of twice.
`data-block-remove` is what keeps a press on the button from
starting a grid drag; see RootGrid's own drag-cancel selector.
The propagation stops are the same idea for the two gestures it
sits inside: a click here removes rather than selects, and a
pointer down here grabs nothing.
The button is nested inside a control, which is not ideal and is
the price of the wrapper itself being selectable — the alternative
was a block you can delete only from the panel. The keyboard path
is not this button: the Outline selects any block with proper tree
semantics and Properties carries the same Delete. */}
{chrome && (
<BlockHeader data-test={`block-header-${nodeId}`}>
{/* 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
the band above it. Subtracted in pixels off a percentage rather
than left to a flex column, because what a leaf block does with
the box is resolve `height: 100%` against it — a chart measures
the result to size its canvas — and that wants a height there is
no question about. */}
<div
data-test={`block-content-${nodeId}`}
style={{
width: '100%',
height: chrome ? `calc(100% - ${headerHeight}px)` : '100%',
}}
>
<ErrorBoundary>
{resolved ?? <UnsupportedBlockPlaceholder nodeId={nodeId} />}
</ErrorBoundary>
</div>
{children}
</div>
);
},
);
export default BuildingBlockView;
@@ -0,0 +1,450 @@
/**
* 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 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();
});
test('returns the singleton instance', () => {
expect(DashboardProvider.getInstance()).toBe(DashboardProvider.getInstance());
});
test('starts with a blank root grid', () => {
const provider = DashboardProvider.getInstance();
expect(provider.getRoot()).toEqual({
id: 'root',
type: 'grid',
layout: { columns: 24, gap: 16 },
children: [],
});
});
test('getNode returns undefined for an unknown id', () => {
expect(DashboardProvider.getInstance().getNode('missing')).toBeUndefined();
});
test('addBuildingBlock inserts a node into the parent at the given index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const firstId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
const secondId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(provider.getRoot().children).toEqual([secondId, firstId]);
});
test('addBuildingBlock clamps an out-of-range index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 99, { type: 'text' });
expect(provider.getRoot().children).toEqual([id]);
});
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: TEST_CONTAINER_TYPE,
});
expect(provider.getNode(id)?.children).toEqual([]);
});
test('addBuildingBlock throws for an unknown parent', () => {
const provider = DashboardProvider.getInstance();
expect(() =>
provider.addBuildingBlock('missing', 0, { type: 'text' }),
).toThrow(/Unknown parent node/);
});
test('addBuildingBlock throws when the parent cannot hold children', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(() => provider.addBuildingBlock(leafId, 0, { type: 'text' })).toThrow(
/not a container/,
);
});
test('removeBuildingBlock detaches the node from its parent', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
provider.removeBuildingBlock(id);
expect(provider.getNode(id)).toBeUndefined();
expect(provider.getRoot().children).toEqual([]);
});
test('removeBuildingBlock removes an entire container subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const containerId = provider.addBuildingBlock(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
provider.removeBuildingBlock(containerId);
expect(provider.getNode(containerId)).toBeUndefined();
expect(provider.getNode(childId)).toBeUndefined();
});
test('removeBuildingBlock throws for the root node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
expect(() => provider.removeBuildingBlock(rootId)).toThrow(
/Cannot remove the root node/,
);
});
test('removeBuildingBlock is a no-op for an unknown id', () => {
const provider = DashboardProvider.getInstance();
const revisionBefore = provider.getRevision();
provider.removeBuildingBlock('missing');
expect(provider.getRevision()).toBe(revisionBefore);
});
test('moveBuildingBlock relocates a node to a new parent at the given index', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const containerId = provider.addBuildingBlock(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
const id = provider.addBuildingBlock(rootId, 1, { type: 'text' });
provider.moveBuildingBlock(id, containerId, 0);
expect(provider.getRoot().children).toEqual([containerId]);
expect(provider.getNode(containerId)?.children).toEqual([id]);
});
test('moveBuildingBlock keeps an explicit position when the parent is unchanged', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const first = provider.addBuildingBlock(rootId, 0, { type: 'text' });
const second = provider.addBuildingBlock(rootId, 1, { type: 'text' });
provider.updateLayout(first, { col: 3, row: 2, colSpan: 6 });
// A move within one parent reorders reading/DOM/tab order alone — the
// node's own placement is not part of what changed.
provider.moveBuildingBlock(first, rootId, 1);
expect(provider.getRoot().children).toEqual([second, first]);
expect(provider.getNode(first)?.layout).toMatchObject({
col: 3,
row: 2,
colSpan: 6,
});
});
test('getParentId returns the container holding a node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const containerId = provider.addBuildingBlock(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
const childId = provider.addBuildingBlock(containerId, 0, { type: 'text' });
expect(provider.getParentId(childId)).toBe(containerId);
expect(provider.getParentId(containerId)).toBe(rootId);
});
test('getParentId returns undefined for the root and for an unknown id', () => {
const provider = DashboardProvider.getInstance();
expect(provider.getParentId(provider.getRoot().id)).toBeUndefined();
expect(provider.getParentId('missing')).toBeUndefined();
});
test('moveBuildingBlock throws when moving the root node', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const containerId = provider.addBuildingBlock(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
expect(() => provider.moveBuildingBlock(rootId, containerId, 0)).toThrow(
/Cannot move the root node/,
);
});
test('moveBuildingBlock throws when the target cannot hold children', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const leafId = provider.addBuildingBlock(rootId, 0, { type: 'text' });
const otherId = provider.addBuildingBlock(rootId, 1, { type: 'text' });
expect(() => provider.moveBuildingBlock(otherId, leafId, 0)).toThrow(
/not a container/,
);
});
test('moveBuildingBlock throws when moving a node into its own subtree', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const containerId = provider.addBuildingBlock(rootId, 0, {
type: TEST_CONTAINER_TYPE,
});
const childContainerId = provider.addBuildingBlock(containerId, 0, {
type: TEST_CONTAINER_TYPE,
});
expect(() =>
provider.moveBuildingBlock(containerId, childContainerId, 0),
).toThrow(/into itself or one of its own descendants/);
});
test("updateLayout merges into the node's existing layout", () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 0, {
type: 'text',
layout: { colSpan: 12 },
});
provider.updateLayout(id, { rowSpan: 2 });
expect(provider.getNode(id)?.layout).toEqual({
colSpan: 12,
rowSpan: 2,
});
});
test('updateLayout throws for an unknown node', () => {
const provider = DashboardProvider.getInstance();
expect(() => provider.updateLayout('missing', { colSpan: 12 })).toThrow(
/Unknown node/,
);
});
test('updateLayout displaces an explicitly placed sibling it now collides with', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const firstId = provider.addBuildingBlock(rootId, 0, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
const secondId = provider.addBuildingBlock(rootId, 1, {
type: 'text',
layout: { col: 1, row: 2, colSpan: 24 },
});
// 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 RootGrid/the grid engine's own collision handling.
provider.updateLayout(firstId, { rowSpan: 2 });
expect(provider.getNode(firstId)?.layout).toEqual({
col: 1,
row: 1,
colSpan: 24,
rowSpan: 2,
});
expect(provider.getNode(secondId)?.layout).toEqual({
col: 1,
row: 3,
colSpan: 24,
});
});
test('addBuildingBlock displaces the new node when it collides with an earlier explicit sibling', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const existingId = provider.addBuildingBlock(rootId, 0, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
// Inserted after `existingId` in children order, so — same rule
// `resolveExplicitCollisions` uses (earlier in `children` order keeps its
// declared position) — it's the new node that gets pushed down, not the
// one already there.
const newId = provider.addBuildingBlock(rootId, 1, {
type: 'text',
layout: { col: 1, row: 1, colSpan: 24 },
});
expect(provider.getNode(existingId)?.layout).toEqual({
col: 1,
row: 1,
colSpan: 24,
});
expect(provider.getNode(newId)?.layout).toEqual({
col: 1,
row: 2,
colSpan: 24,
});
});
test('updateLayout does not resolve collisions for a node with no parent (the root)', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
expect(() => provider.updateLayout(rootId, { gap: 24 })).not.toThrow();
expect(provider.getRoot().layout).toEqual({ columns: 24, gap: 24 });
});
test('updateLayouts merges a layout update into each node in a single commit', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const firstId = provider.addBuildingBlock(rootId, 0, {
type: 'text',
layout: { colSpan: 6 },
});
const secondId = provider.addBuildingBlock(rootId, 1, {
type: 'text',
layout: { colSpan: 6 },
});
const revisionBefore = provider.getRevision();
provider.updateLayouts({
[firstId]: { col: 1, row: 1 },
[secondId]: { col: 7, row: 1 },
});
expect(provider.getNode(firstId)?.layout).toEqual({
colSpan: 6,
col: 1,
row: 1,
});
expect(provider.getNode(secondId)?.layout).toEqual({
colSpan: 6,
col: 7,
row: 1,
});
expect(provider.getRevision()).toBe(revisionBefore + 1);
});
test('updateLayouts silently skips an unknown node id', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const id = provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(() =>
provider.updateLayouts({ missing: { col: 1 }, [id]: { col: 2 } }),
).not.toThrow();
expect(provider.getNode(id)?.layout).toEqual({ col: 2 });
});
test('onDidLayoutChange fires on every mutation', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const listener = jest.fn();
const disposable = provider.onDidLayoutChange(listener);
provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(listener).toHaveBeenCalledTimes(1);
disposable.dispose();
});
test('getRevision increments on every mutation and is stable otherwise', () => {
const provider = DashboardProvider.getInstance();
const rootId = provider.getRoot().id;
const before = provider.getRevision();
expect(provider.getRevision()).toBe(before);
provider.addBuildingBlock(rootId, 0, { type: 'text' });
expect(provider.getRevision()).toBe(before + 1);
});
/**
* Selection is host-internal state, like the revision counter: a property of
* one person looking at one screen, not of the dashboard.
*/
test('selecting a node reports it back', () => {
const provider = DashboardProvider.getInstance();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
});
provider.setSelection(id);
expect(provider.getSelection()).toBe(id);
});
test('removing the selected node clears the selection', () => {
const provider = DashboardProvider.getInstance();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
});
provider.setSelection(id);
provider.removeBuildingBlock(id);
// A selection is a reference to a node, and a node that is gone cannot be
// the thing being edited — an inspector reading a dangling id would show a
// block that no longer exists.
expect(provider.getSelection()).toBeUndefined();
});
test('removing a container clears a selection inside its subtree', () => {
const provider = DashboardProvider.getInstance();
const sectionId = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: TEST_CONTAINER_TYPE,
});
const childId = provider.addBuildingBlock(sectionId, 0, { type: 'markdown' });
provider.setSelection(childId);
provider.removeBuildingBlock(sectionId);
// The node that vanished was a descendant of the one actually removed,
// which is why the check belongs in the commit rather than at the removal.
expect(provider.getSelection()).toBeUndefined();
});
test('reset clears the selection along with the tree', () => {
const provider = DashboardProvider.getInstance();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
});
provider.setSelection(id);
provider.reset();
expect(provider.getSelection()).toBeUndefined();
});
@@ -0,0 +1,459 @@
/**
* 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 { dashboard as dashboardApi } from '@apache-superset/core';
import { createEventEmitter } from '../utils';
import { DEFAULT_COLUMNS } from './layoutStyle';
import { resolveExplicitCollisions } from './gridPacking';
type DashboardNode = dashboardApi.DashboardNode;
type BuildingBlockSpec = dashboardApi.BuildingBlockSpec;
type LayoutProps = dashboardApi.LayoutProps;
/** Node data as stored internally — same as the public `DashboardNode`, minus `id` (the map key already is the id). */
type StoredNode = Omit<DashboardNode, 'id'>;
const ROOT_ID = 'root';
/**
* 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 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 === GRID_TYPE || registeredContainerTypes.has(type);
function createBlankNodes(): Record<string, StoredNode> {
return {
[ROOT_ID]: {
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
// grid is always exactly as tall as its content.
layout: { columns: DEFAULT_COLUMNS, gap: 16 },
children: [],
},
};
}
let nextNodeId = 0;
function generateNodeId(): string {
nextNodeId += 1;
return `node_${nextNodeId}`;
}
/**
* Singleton in-memory store for the active Dashboard v2 prototype's node
* tree. No persistence — deliberately granular (mirroring `sqlLab`'s own
* accessor style) rather than exposing a single "get everything" snapshot:
* callers walk the tree from {@link getRoot} via {@link getNode}.
*
* `getRevision()` is host-internal only (not part of the public API) — a
* cheap invalidation counter the prototype's own canvas renderer subscribes
* to via `useSyncExternalStore`, re-reading whatever nodes it needs through
* the same granular accessors extensions use.
*/
class DashboardProvider {
private static instance: DashboardProvider;
private nodes: Record<string, StoredNode> = createBlankNodes();
private revision = 0;
/**
* Which node the author is working on.
*
* Host-internal, exactly like {@link getRevision} and for the same reason:
* it is a property of one person looking at one screen, not of the
* dashboard. Two people opening the same tree select different things, and
* nothing about a selection belongs in a document or in the public API an
* extension calls.
*
* It lives here rather than in page state because the canvas draws it and
* the editor panel reads it, and those sit in different layers — putting it
* in the one place both already subscribe to beats threading it through the
* render tree that `BuildingBlockView` deliberately keeps ignorant.
*/
private selection: string | undefined;
private layoutChangeEmitter = createEventEmitter<void>();
private stateSubscribers = new Set<() => void>();
public static getInstance(): DashboardProvider {
if (!DashboardProvider.instance) {
DashboardProvider.instance = new DashboardProvider();
}
return DashboardProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
public getRevision = (): number => this.revision;
public getSelection = (): string | undefined => this.selection;
/**
* Selects a node, or clears the selection with `undefined`.
*
* Ticks the same revision every mutation does, so everything already
* subscribed re-reads without needing a second subscription of its own.
*/
public setSelection = (id: string | undefined): void => {
if (this.selection === id) {
return;
}
this.selection = id;
this.revision += 1;
this.stateSubscribers.forEach(fn => fn());
};
private commit(nodes: Record<string, StoredNode>): void {
// A selection is a reference to a node, and a node that is gone cannot be
// the thing being edited. Clearing it here — rather than at each removal
// site — covers a subtree deletion too, where the node that vanished was
// a descendant of the one actually removed.
if (this.selection !== undefined && !nodes[this.selection]) {
this.selection = undefined;
}
this.nodes = nodes;
this.revision += 1;
this.layoutChangeEmitter.fire();
this.stateSubscribers.forEach(fn => fn());
}
private toNode(id: string): DashboardNode | undefined {
const data = this.nodes[id];
return data ? { id, ...data } : undefined;
}
public getRoot = (): DashboardNode => this.toNode(ROOT_ID)!;
public getNode = (id: string): DashboardNode | undefined => this.toNode(id);
/**
* 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
* — a caller reordering a node within its own container most of all. The
* walk itself is one line, and leaving it out meant each caller wrote that
* line again over a `nodes` map only this class is supposed to hold.
*/
public getParentId = (id: string): string | undefined =>
this.findParentId(id, this.nodes);
/** True if `targetId` is `nodeId` itself or nested somewhere in its subtree. */
private isNodeOrDescendant(nodeId: string, targetId: string): boolean {
if (nodeId === targetId) return true;
return (
this.nodes[nodeId]?.children?.some(childId =>
this.isNodeOrDescendant(childId, targetId),
) ?? false
);
}
private findParentId(
id: string,
nodes: Record<string, StoredNode>,
): string | undefined {
return Object.entries(nodes).find(([, node]) =>
node.children?.includes(id),
)?.[0];
}
/**
* Displaces any of `parentId`'s explicitly placed children that now
* collide with one another (see {@link resolveExplicitCollisions}) and
* folds the result into `nodes`. `addBuildingBlock`/`updateLayout` are the
* two ways an extension's AI tools place a node without going through
* `RootGrid`'s interactive drag/resize at all — this gives that
* programmatic path the same "nothing ends up stuck overlapping"
* guarantee a mouse-driven resize gets for free from the grid engine
* itself, rather than leaving it to whatever the renderer happens to
* paper over on screen without ever writing the correction back to the
* store.
*/
private resolveParentCollisions(
parentId: string,
nodes: Record<string, StoredNode>,
): Record<string, StoredNode> {
const parent = nodes[parentId];
if (!parent?.children) return nodes;
const columns = parent.layout?.columns ?? DEFAULT_COLUMNS;
const getNode = (nodeId: string): DashboardNode | undefined => {
const data = nodes[nodeId];
return data ? { id: nodeId, ...data } : undefined;
};
const adjustments = resolveExplicitCollisions(
parent.children,
columns,
getNode,
);
if (Object.keys(adjustments).length === 0) return nodes;
const result = { ...nodes };
Object.entries(adjustments).forEach(([id, layout]) => {
const node = result[id];
if (node) result[id] = { ...node, layout: { ...node.layout, ...layout } };
});
return result;
}
public addBuildingBlock(
parentId: string,
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 container)`,
);
}
const id = generateNodeId();
const node: StoredNode = {
type: spec.type,
layout: spec.layout,
props: spec.props,
style: spec.style,
...(isContainerType(spec.type) ? { children: [] } : {}),
};
const children = [...parent.children];
const clampedIndex = Math.max(0, Math.min(index, children.length));
children.splice(clampedIndex, 0, id);
const nodes = {
...this.nodes,
[parentId]: { ...parent, children },
[id]: node,
};
this.commit(this.resolveParentCollisions(parentId, nodes));
return id;
}
public removeBuildingBlock(id: string): void {
if (id === ROOT_ID) {
throw new Error('[dashboard] Cannot remove the root node');
}
if (!this.nodes[id]) return;
const nodes = { ...this.nodes };
const removeSubtree = (nodeId: string) => {
nodes[nodeId]?.children?.forEach(removeSubtree);
delete nodes[nodeId];
};
removeSubtree(id);
Object.entries(nodes).forEach(([parentId, parent]) => {
if (parent.children?.includes(id)) {
nodes[parentId] = {
...parent,
children: parent.children.filter(childId => childId !== id),
};
}
});
this.commit(nodes);
}
public moveBuildingBlock(
id: string,
newParentId: string,
newIndex: number,
): void {
if (id === ROOT_ID) {
throw new Error('[dashboard] Cannot move the root node');
}
if (!this.nodes[id]) {
throw new Error(`[dashboard] Unknown node "${id}"`);
}
if (!this.nodes[newParentId]?.children) {
throw new Error(
`[dashboard] Node "${newParentId}" cannot hold children (not a container)`,
);
}
if (this.isNodeOrDescendant(id, newParentId)) {
throw new Error(
`[dashboard] Cannot move node "${id}" into itself or one of its own descendants`,
);
}
const oldParentId = this.findParentId(id, this.nodes);
const nodes = { ...this.nodes };
Object.entries(nodes).forEach(([parentId, parent]) => {
if (parent.children?.includes(id)) {
nodes[parentId] = {
...parent,
children: parent.children.filter(childId => childId !== id),
};
}
});
const targetParent = nodes[newParentId];
const children = [...(targetParent.children ?? [])];
const clampedIndex = Math.max(0, Math.min(newIndex, children.length));
children.splice(clampedIndex, 0, id);
nodes[newParentId] = { ...targetParent, children };
// An explicit col/row (or a colSpan wider than the new parent's own
// 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 `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.
//
// None of which is true when the parent has not changed. A move within
// one container is a reorder of reading/DOM/tab order alone, and the
// position it keeps is the one the author placed it at. Resetting it
// would teleport the block to auto-placement as the price of a reorder.
if (oldParentId !== newParentId) {
const node = nodes[id];
const destColumns = targetParent.layout?.columns ?? DEFAULT_COLUMNS;
nodes[id] = {
...node,
layout: {
...node.layout,
col: undefined,
row: undefined,
colSpan:
node.layout?.colSpan != null
? Math.min(node.layout.colSpan, destColumns)
: undefined,
},
};
}
this.commit(nodes);
}
public updateLayout(id: string, layout: Partial<LayoutProps>): void {
const node = this.nodes[id];
if (!node) {
throw new Error(`[dashboard] Unknown node "${id}"`);
}
const nodes = {
...this.nodes,
[id]: { ...node, layout: { ...node.layout, ...layout } },
};
const parentId = this.findParentId(id, nodes);
this.commit(
parentId ? this.resolveParentCollisions(parentId, nodes) : nodes,
);
}
/**
* Merges a `layout` update into each of several nodes at once, in a single
* 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
* once for the whole gesture.
*/
public updateLayouts(updates: Record<string, Partial<LayoutProps>>): void {
const nodes = { ...this.nodes };
Object.entries(updates).forEach(([id, layout]) => {
const node = nodes[id];
if (!node) return;
nodes[id] = { ...node, layout: { ...node.layout, ...layout } };
});
this.commit(nodes);
}
/**
* Shallow-merges `props` into a node's existing props — the content-side
* counterpart to {@link updateLayout}. Lets a chart's `echartsOptions`
* (or a markdown block's `content`) be edited in place, instead of the
* only alternative being remove + re-add, which loses the node's
* position, layout, and identity just to change what it renders.
*/
public updateProps(id: string, props: Record<string, unknown>): void {
const node = this.nodes[id];
if (!node) {
throw new Error(`[dashboard] Unknown node "${id}"`);
}
this.commit({
...this.nodes,
[id]: { ...node, props: { ...node.props, ...props } },
});
}
public get onDidLayoutChange() {
return this.layoutChangeEmitter.subscribe;
}
/** Test/demo helper — discards all nodes back to a blank canvas. */
public reset(): void {
this.nodes = createBlankNodes();
this.selection = undefined;
this.revision = 0;
this.layoutChangeEmitter = createEventEmitter<void>();
this.stateSubscribers.clear();
}
}
export default DashboardProvider;
@@ -0,0 +1,342 @@
/**
* 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 RootGrid from './RootGrid';
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
import {
__getLastGridStackInstance,
__resetGridStackMock,
} from '../../../spec/__mocks__/gridstackMock';
beforeAll(() => {
registerBuiltInBuildingBlocks();
});
/**
* `GridStack.init` measures the container it's given — meaningless in
* jsdom, which has no layout engine — so every test that exercises the live
* drop preview or an actual drop position needs a non-zero width to divide
* columns into. 1200 is an arbitrary round number; nothing here asserts an
* exact pixel value (that's `layoutStyle.test.ts`'s job), only that a ghost
* appears/disappears and a drop lands in the right *cell*.
*/
const CONTAINER_WIDTH = 1200;
const provider = DashboardProvider.getInstance();
beforeEach(() => {
provider.reset();
__resetGridStackMock();
// jsdom has no layout engine and does not implement this at all (not
// even as a stub returning `null`) — every test that ends a drag has to
// go through it (`findContainerIdAt`), whether or not that particular
// test cares where it points.
document.elementFromPoint = jest.fn().mockReturnValue(null);
jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: CONTAINER_WIDTH,
height: 800,
top: 0,
left: 0,
right: CONTAINER_WIDTH,
bottom: 800,
x: 0,
y: 0,
toJSON: () => {},
});
});
afterEach(() => {
jest.restoreAllMocks();
});
const mount = () => {
const rootId = provider.getRoot().id;
const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
render(<RootGrid nodeId={rootId} />);
return { rootId, first, second };
};
/** A drag payload jsdom's synthetic events do not carry on their own. */
const paletteTransfer = (type: string) => {
const data = new Map([['application/x-dashboard-building-block', type]]);
return {
types: [...data.keys()],
getData: (key: string) => data.get(key) ?? '',
setData: (key: string, value: string) => data.set(key, value),
dropEffect: '',
effectAllowed: '',
};
};
test('the grid initializes with the root layout mapped onto GridStack options', () => {
mount();
const grid = __getLastGridStackInstance();
expect(grid?.options).toMatchObject({
column: 24,
// 48/8: rowUnitPx(32) + gap(16), and gap(16)/2 — pinned explicitly since
// getting either wrong silently regresses every block's own height.
cellHeight: 48,
margin: 8,
// Collision avoidance is always on regardless of this; `float: true`
// only disables GridStack's own compaction pass, which would otherwise
// fight `packChildLayout`'s own auto-placement for the same job.
float: true,
acceptWidgets: false,
removable: false,
animate: false,
// GridStack's own default (`auto: true`) claims every `.grid-stack-item`
// already in the DOM the moment `init` runs, with no `id` — and React
// has already rendered every initially-mounted item by then. Left on,
// every one of those items' `id` stays undefined forever, so every
// future gesture-end commit (`readGestureItems`'s `!!node?.id` filter)
// silently drops it and it springs back to its last position on the
// very next drag or resize.
auto: false,
});
});
test('a block resizes from all four corners', () => {
mount();
const grid = __getLastGridStackInstance();
expect(grid?.options.resizable).toEqual({ handles: 'se, sw, nw, ne' });
});
test('the grid is told not to start a drag from the remove control, a nested container, a flow resize grip, or a header control', () => {
mount();
// GridStack's own draggable engine matches this selector up the ancestors
// of whatever was pressed — aiming at the bin would otherwise drag the
// block it is attached to, and dragging a chart out of a `tabs` block
// would instead drag the whole `tabs` block on the root grid.
const grid = __getLastGridStackInstance()!;
const { cancel } = grid.options.draggable as { cancel: string };
expect(cancel).toContain('[data-block-remove]');
expect(cancel).toContain('[data-container-id]');
expect(cancel).toContain('[data-block-resize]');
expect(cancel).toContain('[data-block-header-control]');
});
test('every child is registered with GridStack at its packed position', () => {
const { first, second } = mount();
const grid = __getLastGridStackInstance();
expect(grid?.makeWidget).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: first, x: 0, y: 0 }),
);
expect(grid?.makeWidget).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: second }),
);
});
test('ending a drag on empty grid space commits every items settled position', () => {
const { first, second } = mount();
const grid = __getLastGridStackInstance()!;
const el = grid
.getGridItems()
.find(item => item.gridstackNode?.id === first)!;
el.gridstackNode = { id: first, x: 4, y: 2, w: 6, h: 3 };
grid.__trigger('dragstop', el);
expect(provider.getNode(first)?.layout).toMatchObject({
col: 5,
row: 3,
colSpan: 6,
rowSpan: 3,
});
// The untouched sibling still commits its own unchanged position — the
// commit reads every item GridStack currently knows about, not just the
// one the gesture ended on.
expect(provider.getNode(second)?.layout).toBeDefined();
});
test('ending a resize on empty grid space commits the resized items new span', () => {
const { first } = mount();
const grid = __getLastGridStackInstance()!;
const el = grid
.getGridItems()
.find(item => item.gridstackNode?.id === first)!;
el.gridstackNode = { id: first, x: 0, y: 0, w: 12, h: 5 };
grid.__trigger('resizestop', el);
expect(provider.getNode(first)?.layout).toMatchObject({
colSpan: 12,
rowSpan: 5,
});
});
test('ending a drag over a nested container reparents instead of committing a layout', () => {
const { rootId, first } = mount();
let collapsibleId = '';
act(() => {
collapsibleId = provider.addBuildingBlock(rootId, 1, {
type: 'collapsible',
});
});
const grid = __getLastGridStackInstance()!;
const el = grid
.getGridItems()
.find(item => item.gridstackNode?.id === first)!;
// Stubbed (see the `beforeEach` above) to return the collapsible's own
// rendered container element, exactly what a real hit-test would find if
// the drag actually ended over it.
(document.elementFromPoint as jest.Mock).mockReturnValue(
document.querySelector(`[data-container-id="${collapsibleId}"]`),
);
grid.__trigger('dragstop', el);
expect(provider.getNode(collapsibleId)?.children).toContain(first);
expect(provider.getNode(rootId)?.children).not.toContain(first);
});
test('ending a drag over its own container commits a layout instead of reparenting', () => {
const { rootId, first } = mount();
const grid = __getLastGridStackInstance()!;
const el = grid
.getGridItems()
.find(item => item.gridstackNode?.id === first)!;
el.gridstackNode = { id: first, x: 3, y: 1, w: 4, h: 2 };
// The root grid's own surface also carries `data-container-id` — landing
// back on the container the block already belongs to must not be read as
// a reparent onto itself.
(document.elementFromPoint as jest.Mock).mockReturnValue(
screen.getByTestId('grid-container'),
);
grid.__trigger('dragstop', el);
expect(provider.getNode(rootId)?.children).toContain(first);
expect(provider.getNode(first)?.layout).toMatchObject({
col: 4,
row: 2,
colSpan: 4,
rowSpan: 2,
});
});
test('dropping a palette block on the grid places it at the resolved cell', () => {
const { rootId } = mount();
fireEvent.dragOver(
screen.getByTestId('grid-container').querySelector('.grid-stack')!,
{
dataTransfer: paletteTransfer('markdown'),
clientX: 50,
clientY: 10,
},
);
fireEvent.drop(
screen.getByTestId('grid-container').querySelector('.grid-stack')!,
{
dataTransfer: paletteTransfer('markdown'),
clientX: 50,
clientY: 10,
},
);
const children = provider.getNode(rootId)?.children ?? [];
expect(children).toHaveLength(3);
expect(provider.getNode(children[2])?.type).toBe('markdown');
});
test('dropping a palette block past the grids own rendered rows appends it at the end', () => {
const { rootId } = mount();
fireEvent.drop(screen.getByTestId('grid-container'), {
dataTransfer: paletteTransfer('markdown'),
});
const children = provider.getNode(rootId)?.children ?? [];
expect(children).toHaveLength(3);
expect(provider.getNode(children[2])?.type).toBe('markdown');
});
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('grid-container'), {
dataTransfer: {
types: ['text/plain'],
getData: () => '',
dropEffect: '',
effectAllowed: '',
},
});
// A private type rather than text/plain is what keeps a dragged file, or a
// selection of text from another window, from placing a block.
expect(provider.getNode(rootId)?.children?.length).toBe(before);
});
test('a placed block offers a way to remove it, and the root does not', () => {
const { rootId, first } = mount();
expect(screen.getByTestId(`block-remove-${first}`)).toBeInTheDocument();
// Removing the root is refused by the provider, so offering the button
// would be offering an error.
expect(
screen.queryByTestId(`block-remove-${rootId}`),
).not.toBeInTheDocument();
});
test('the remove control removes that block and nothing else', () => {
const { rootId, first, second } = mount();
fireEvent.click(screen.getByTestId(`block-remove-${first}`));
expect(provider.getNode(rootId)?.children).toEqual([second]);
});
test('clicking the remove control removes rather than selects', () => {
const { first, second } = mount();
fireEvent.click(screen.getByTestId(`block-remove-${second}`));
// The wrapper selects on click and the button sits inside it. Without the
// stop, removing a block would also try to select the thing just removed.
expect(provider.getSelection()).toBeUndefined();
expect(provider.getNode(second)).toBeUndefined();
expect(provider.getNode(first)).toBeDefined();
});
test('unmounting a block removes its widget from GridStack without touching the DOM', () => {
const { rootId, first } = mount();
act(() => provider.removeBuildingBlock(first));
const grid = __getLastGridStackInstance();
expect(grid?.removeWidget).toHaveBeenCalled();
expect(provider.getNode(rootId)?.children).not.toContain(first);
});
@@ -0,0 +1,740 @@
/**
* 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 { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DragEvent as ReactDragEvent } from 'react';
import 'gridstack/dist/gridstack.min.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 {
cellAtPoint,
pixelRectForCell,
resolveCellGeometry,
resolveGridMetrics,
} from './layoutStyle';
import { packChildLayout, resolveDropPlacement } from './gridPacking';
import type { DropPlacement, PackedRect } from './gridPacking';
import {
FALLBACK_COL_SPAN,
FALLBACK_ROW_SPAN,
PALETTE_MIME,
placeBlock,
placeBlockAt,
} from './placement';
import { useGridStack } from './useGridStack';
import type { GestureEnd, GestureEndItem } from './useGridStack';
import BuildingBlockView from './BuildingBlockView';
type LayoutProps = dashboardApi.LayoutProps;
/**
* The `draggableCancel`-equivalent guard: regions a press must never start a
* drag from. `[data-container-id]` is a nested container (dragging one chart
* out of a `tabs` block must not drag the whole `tabs` block); `[data-block-
* remove]` is a block's own remove control; `[data-block-resize]` is a flowed
* block's own resize grip (`flowContent.tsx`); `[data-block-header-control]`
* is a type's own extra header control (e.g. `collapsible`'s toggle).
*
* `nodeId` is threaded through and excluded from the `data-container-id`
* clause via `:not(...)` — GridStack matches this with a plain, unbounded
* `e.target.closest(cancel)`, which walks every ancestor all the way to
* `document`, not just the ones between the press and the widget being
* dragged. `GridSurface` (this very grid's own outer element, below) itself
* carries `data-container-id={nodeId}` — the marker every *other* container
* on the page also carries, and the one this grid's own drop-target/defer
* checks need it to have — so without the exclusion, *every* press inside
* this grid's own boundary matches its own ancestor marker and cancels
* every drag unconditionally, leaving only resize (a separate code path
* that never consults `cancel` at all) working. A different value here
* (any other container actually nested inside the widget being dragged)
* still matches and still cancels, exactly as intended.
*/
function cancelSelectorFor(nodeId: string): string {
return `[data-container-id]:not([data-container-id="${CSS.escape(nodeId)}"]),[data-block-remove],[data-block-resize],[data-block-header-control]`;
}
/**
* The surface everything else in this file draws on — deliberately not a
* scroll container of its own. The page's own `Canvas` (`DashboardBuilderV2`)
* is the one authoritative scrollable region for the whole editor; `overflow`
* is left at its default `visible` here (no `overflow-x`/`overflow-y` of its
* own) so content taller than this element simply paints past its edge
* instead of this element clipping or independently scrolling it — two
* nested `overflow: auto` boxes would each try to own the same scroll
* gesture, which reads as broken scrolling rather than as "the canvas is
* tall, scroll it".
*
* `height: 100%`, deliberately not `min-height: 100%` — this has to be a
* value CSS treats as "specified explicitly" for `GridStackContainer`'s own
* `min-height: 100%` (below) to resolve against a definite number at all.
* `min-height` on *this* element does not count as that, per spec (the used
* height still "depends on content height"), so swapping this to
* `min-height: 100%` — the seemingly-symmetric choice with the child below —
* silently collapses the child's own floor to nothing whenever content is
* shorter than the viewport. `overflow: visible` is what actually keeps
* taller content from clipping here; `height` (not `min-height`) is what
* keeps the drop target's own floor intact when content is shorter.
*
* `.grid-stack-item-content`'s own default CSS gives it `overflow-y: auto`
* — undone here, since `BuildingBlockView`'s own card already decides how
* its content overflows (clipped, per its own `overflow: hidden`), and a
* second, independent scroll container around it would fight that rather
* than help it.
*/
const GridSurface = styled.div`
width: 100%;
height: 100%;
.grid-stack-item-content {
overflow: visible;
}
/*
* GridStack's own resize-handle CSS draws its corner glyph at the full
* size of its own 20px hit box (viewBox="0 0 20 20", stroke-width 2) —
* bold enough to read as a UI element in its own right rather than the
* small, quiet corner grip react-grid-layout drew before this migration.
* Shrinking only the glyph (not the box itself, left alone so the actual
* clickable/touchable corner stays exactly as forgiving as GridStack's
* own default) keeps the affordance without it visually dominating the
* card corner it sits on.
*/
.grid-stack-item > .ui-resizable-handle {
background-size: 10px 10px;
}
`;
/**
* The `.grid-stack` element itself — GridStack's own required class, plus a
* CSS floor so there is always somewhere to drop "below" sparse content:
* left to its own content-driven height, dropping past the last row would
* mean 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).
*/
const GridStackContainer = styled.div`
min-height: 100%;
`;
/**
* The live drop preview — not GridStack's own placeholder (this grid never
* hands GridStack the palette drag at all; see `useGridStack`'s own doc
* comment for why), a plain box this component positions itself from
* `resolveDropPlacement`'s own answer, in the app's primary colour. Filling
* this in and rendering it are one step, in one place, for the same reason
* `handleGridDrop`, below, resolves the actual drop from the exact same
* function: what an author sees while hovering is provably what they get.
*/
const DropGhost = styled.div`
${({ theme }) => css`
position: absolute;
pointer-events: none;
background-color: ${theme.colorPrimaryBg};
border: 2px dashed ${theme.colorPrimary};
border-radius: ${theme.borderRadiusLG}px;
`}
`;
/**
* 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
);
}
function rectsEqual(a: PackedRect, b: PackedRect): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
/**
* One child, registered with GridStack via the two-level DOM structure its
* own CSS requires (`.grid-stack-item` > `.grid-stack-item-content`) —
* `BuildingBlockView` itself needs no special handling for this, since it
* only ever has to fill 100% of whatever box it's given, the same as it
* already does for a flowed block (`flowContent.tsx`'s `FlowItem`).
*
* `useLayoutEffect`, not `useEffect`: cleanup has to run, and
* `unregisterItem` has to call `removeWidget`, before React detaches the
* element, not after — a layout effect's cleanup runs before the DOM
* mutation that unmounts it, where a passive effect's would run after.
*
* `registerItem`/`unregisterItem` only ever record which element belongs to
* which node id here — nothing calls `makeWidget` from this component at
* all. `useGridStack`'s own sync effect is what actually tells GridStack
* about a newly-registered element, and it does that regardless of whether
* this effect happened to run before or after `useGridStack`'s own init
* effect created the `GridStack` instance in the first place (see that
* hook's own doc comment) — this component doesn't have to know or care
* which happened first.
*/
function GridStackItem({
nodeId,
registerItem,
unregisterItem,
}: {
nodeId: string;
registerItem: (id: string, el: HTMLDivElement) => void;
unregisterItem: (id: string) => void;
}) {
const elRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return undefined;
registerItem(nodeId, el);
return () => unregisterItem(nodeId);
}, [nodeId, registerItem, unregisterItem]);
return (
<div ref={elRef} className="grid-stack-item">
<div className="grid-stack-item-content">
<BuildingBlockView
nodeId={nodeId}
style={{ width: '100%', height: '100%' }}
/>
</div>
</div>
);
}
/**
* The dashboard's own grid — not a Building Block (see the composition/
* layout design doc), which is why it lives here rather than in `blocks/`
* 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 GridStack (see `useGridStack`, the only place that package is
* imported). All position/size math — including collision handling — is
* GridStack's: resizing a block never shrinks a sibling, only displaces it
* to the next open slot, which leaves every block's own authored (hand- or
* AI-set) span untouched — except a left/right *drop*, the one deliberate
* exception, which does shrink a sibling (see `resolveDropPlacement`).
* This component's job is translating between the stored
* `col`/`row`/`colSpan`/`rowSpan` schema (which allows a child to omit its
* position entirely, to be auto-placed) and GridStack's own `{x, y, w, h}`
* — see `packChildLayout` for the auto-placement piece GridStack has no
* concept of — and committing back to the store only once a gesture ends,
* never on every intermediate frame.
*
* The palette's own native HTML5 drag stays exactly as it always has —
* GridStack's own drag-in system uses a pointer-based DD engine that can't
* see `dataTransfer`, so this draws its own drop preview (`DropGhost`)
* instead of handing the gesture to GridStack at all.
*
* Dragging a block into a *different* container (reparenting, as opposed to
* repositioning within this one) is handled by hit-testing which
* `data-container-id` is under the pointer when the drag ends — deliberately
* not something GridStack (scoped to one grid instance) handles on its own.
*/
export default function RootGrid({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const theme = useTheme();
const node = provider.getNode(nodeId);
// Ahead of the early return below, and computed off `node?.layout` rather
// than `node.layout`: every hook this component calls (`useGridStack`
// among them) has to run on every render, in the same order, whether or
// not `node` turns out to exist.
const metrics = resolveGridMetrics(node?.layout, theme);
const children = node?.children ?? [];
const packed = packChildLayout(children, metrics.columns, provider.getNode);
// Not shown while hovering a nested container (see `handleGridDragOver`)
// — that container's own drop target is where the preview belongs, not
// here (see this component's own doc comment on the identical check the
// previous, react-grid-layout-backed version of this made).
const [ghostRect, setGhostRect] = useState<PackedRect | null>(null);
// The existing block's own *shrunk* half, while the cursor is hovering the
// left/right split band of it — `resolveDropPlacement`'s `shrink`. Not a
// second placeholder box: it feeds `previewPacked` (below, where
// `useGridStack` is called), which makes the *real* block visibly resize
// to this rect for as long as the hover lasts — the same way a live drag
// or resize elsewhere on this grid is never represented by a stand-in box
// either. Kept as its own piece of state (rather than folded into
// `ghostRect`) since committing a split still needs to know which
// sibling and by how much, same as it always did.
const [shrinkPreview, setShrinkPreview] = useState<{
id: string;
rect: PackedRect;
} | null>(null);
// A counter, not a plain boolean: this element isn't a single node, it's
// the grid plus every item already on it, and the pointer crossing from
// the container onto one of those fires a `dragleave` on the container
// immediately followed by a `dragenter` on the child — a plain boolean
// would read that as leaving entirely and flicker the ghost off for a
// frame. Only reaching zero really means "gone".
const dragOverCountRef = useRef(0);
// `dragleave`/`drop` alone are not a complete story: releasing the
// pointer somewhere that never became a drop target at all (past every
// edge of the browser window, over a panel that isn't a drop target,
// or the drag simply being cancelled with `Escape`) fires neither one on
// this grid — the browser fires `dragend` on the *drag source* instead
// (`Palette.tsx`'s own item), which bubbles to `document` regardless of
// where the pointer ended up. This is the backstop for exactly that: it
// always fires exactly once when a drag concludes, however it concluded,
// so the ghost (and the enter/leave counter it depends on) can never be
// left stuck on screen waiting for a `dragleave` that was never coming.
useEffect(() => {
const handleDragEnd = () => {
dragOverCountRef.current = 0;
setGhostRect(null);
setShrinkPreview(null);
};
document.addEventListener('dragend', handleDragEnd);
return () => document.removeEventListener('dragend', handleDragEnd);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// `handleGestureEnd`'s own real definition needs `containerRef` (returned
// by the `useGridStack` call below) and `resolvePlacementAtPoint`/`columns`
// (declared further down, after the `!node` early return) — but
// `useGridStack` needs a gesture-end callback *now*, up here, ahead of
// both, since every hook this component calls has to run unconditionally,
// before any early return. This ref is the seam: `useGridStack` is handed
// a stable function that only ever calls whatever this ref currently
// holds, and the real `handleGestureEnd`, defined later once everything
// it needs already exists, is written into it every render — the same
// pattern `useGridStack` itself already uses internally for the identical
// reason (see its own `onGestureEndRef`).
const handleGestureEndRef = useRef<
(items: GestureEndItem[], gesture: GestureEnd) => void
>(() => {});
// What `useGridStack` actually renders — `packed` with the split target's
// own entry substituted for its shrunk-to half while `shrinkPreview` is
// set. This is the real block visibly resizing to preview the split, not
// a second placeholder box drawn over it: `useGridStack`'s own sync
// effect diffs this against GridStack's current node and calls `update`
// on it exactly the way a committed resize would, then diffs right back
// to `packed` the moment `shrinkPreview` clears (hover moves on, or the
// drag ends), snapping the real block back to its actual current size.
// `resolveGridDropPlacement`/hit-testing below still reads the real,
// unmodified `packed` — resolving a placement against an already-shrunk
// neighbor would misread where its true edges are.
const previewPacked = shrinkPreview
? { ...packed, [shrinkPreview.id]: shrinkPreview.rect }
: packed;
const { containerRef, registerItem, unregisterItem } = useGridStack({
metrics,
packed: previewPacked,
cancelSelector: cancelSelectorFor(nodeId),
onGestureEnd: (items, gesture) =>
handleGestureEndRef.current(items, gesture),
});
if (!node) return null;
const { columns } = metrics;
/**
* The core both `resolveGridDropPlacement` (below, a palette drag's own
* `DragEvent`) and the reposition-split check in `handleGestureEnd` (a
* `dragstop` gesture's own `clientX`/`clientY`, which carries no
* `currentTarget` of its own to measure) ultimately want: what does
* `resolveDropPlacement` say about this point, against this occupancy.
* The two differ only in *how* they get the point and *which* occupancy
* map to ask against — a reposition excludes the item being dragged from
* it (it is the thing about to get a new position, not a fixed point to
* test anyone else against); a fresh palette block was never in `packed`
* to begin with, so the plain, unmodified map is already correct for it.
*/
const resolvePlacementAtPoint = (
clientX: number,
clientY: number,
containerRect: DOMRect,
packedForHitTest: Record<string, PackedRect> = packed,
): DropPlacement => {
const cellGeometry = resolveCellGeometry(metrics, containerRect.width);
const { col, row } = cellAtPoint(
clientX - containerRect.left,
clientY - containerRect.top,
cellGeometry,
);
const exactCol = Math.min(columns - Number.EPSILON, Math.max(0, col));
const exactRow = Math.max(0, row);
return resolveDropPlacement(
packedForHitTest,
columns,
exactCol,
exactRow,
FALLBACK_ROW_SPAN,
FALLBACK_COL_SPAN,
);
};
/**
* Where a palette drag over this grid actually resolves — the single
* entry point `handleGridDragOver`'s own live preview and `handleGridDrop`
* both call with the same inputs, so what an author sees while hovering is
* provably what they get on release.
*/
const resolveGridDropPlacement = (
event: ReactDragEvent<HTMLDivElement>,
): DropPlacement =>
resolvePlacementAtPoint(
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
);
/**
* Where a palette drag actually lands, or where an existing block's own
* drag/resize settles — the single sink both gestures write through,
* mirroring `handleGridDrop`/`handleExternalDrop`'s own "one path, so
* preview and outcome can't quietly diverge" reasoning.
*
* A *drag* (never a resize) can be one of two other things before it is
* ever just "committing wherever GridStack's own collision engine parked
* everything":
*
* 1. A reparent — the dragged element's own bounding-rect centre is
* hit-tested for a `data-container-id` other than this one. Landing on
* itself or one of its own descendants throws (`moveBuildingBlock`'s
* own guard); that's caught and falls through to the checks below,
* same as any other drag.
* 2. A split — the cursor's own final position (`gesture.clientX/clientY`,
* which GridStack's own `dd-draggable` copies off the underlying mouseup)
* is resolved through the same `resolvePlacementAtPoint` a palette
* drop's own preview/drop pair use, against every *other* sibling's own
* real, pre-drag position (the dragged item's own entry is excluded —
* it is the thing about to get a new position, not a fixed point to
* test anyone else against). Landing on a sibling's left/right split
* band shrinks that sibling and resizes the dragged item into the other
* half, in one commit — dragging an existing block onto another's half
* is meant to read as the identical gesture a palette drop landing
* there already is, not a lesser version of it that only ever pushes
* things down instead.
*/
const handleGestureEnd = (
items: GestureEndItem[],
gesture: GestureEnd,
): void => {
if (gesture.kind === 'drag') {
const rect = gesture.el.getBoundingClientRect();
const targetContainerId = findContainerIdAt(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
gesture.el,
);
if (targetContainerId && targetContainerId !== nodeId) {
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(gesture.id, targetContainerId, destIndex);
return;
} catch {
// Dropped onto itself or one of its own descendants — not a valid
// reparent target. Fall through and commit layout instead.
}
}
const containerRect = containerRef.current?.getBoundingClientRect();
if (containerRect) {
const { [gesture.id]: _dragged, ...siblingsOnly } = packed;
const placement = resolvePlacementAtPoint(
gesture.clientX,
gesture.clientY,
containerRect,
siblingsOnly,
);
if (placement.shrink) {
const updates: Record<string, Partial<LayoutProps>> = {};
items.forEach(({ id, rect: itemRect }) => {
updates[id] = {
col: itemRect.x + 1,
row: itemRect.y + 1,
colSpan: itemRect.w,
rowSpan: itemRect.h,
};
});
updates[gesture.id] = {
col: placement.rect.x + 1,
row: placement.rect.y + 1,
colSpan: placement.rect.w,
rowSpan: placement.rect.h,
};
updates[placement.shrink.id] = {
col: placement.shrink.rect.x + 1,
row: placement.shrink.rect.y + 1,
colSpan: placement.shrink.rect.w,
rowSpan: placement.shrink.rect.h,
};
provider.updateLayouts(updates);
return;
}
}
}
const updates: Record<string, Partial<LayoutProps>> = {};
items.forEach(({ id, rect }) => {
updates[id] = {
col: rect.x + 1,
row: rect.y + 1,
colSpan: rect.w,
rowSpan: rect.h,
};
});
provider.updateLayouts(updates);
};
handleGestureEndRef.current = handleGestureEnd;
/**
* Live preview while a palette drag hovers this grid's own rendered
* area — the counterpart to `handleGridDrop`, below, which resolves the
* actual drop from the exact same `resolveGridDropPlacement` call so what
* an author sees while hovering is provably what they get on release.
* Drives both `ghostRect` (the new block's own preview) and
* `shrinkPreview` (the existing block's shrunk half, only set while
* hovering a left/right split band — see `resolveDropPlacement`'s own
* doc comment for the other two bands, which never set it).
*
* Hovering over a *nested* container (a `tabs`/`collapsible`/`carousel`
* block sitting on this grid, or anything a third party contributes)
* clears both previews instead: that container has its own drop target
* (`FlowContent`/`EmptyArea`, tagged `data-container-id`, the same way
* this component's own `GridSurface` is) and is where the block actually
* belongs, not beside or on top of the container's own card on *this*
* grid. Without this, both would react to the same hover, and only one of
* them ever runs its own cleanup on drop.
*/
const handleGridDragOver = (event: ReactDragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes(PALETTE_MIME)) return;
const hoveredContainerId = (
event.target as HTMLElement
).closest<HTMLElement>('[data-container-id]')?.dataset.containerId;
if (hoveredContainerId && hoveredContainerId !== nodeId) {
setGhostRect(null);
setShrinkPreview(null);
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
const { rect, shrink } = resolveGridDropPlacement(event);
setGhostRect(previous =>
previous && rectsEqual(previous, rect) ? previous : rect,
);
setShrinkPreview(previous => {
if (!shrink) return previous === null ? previous : null;
if (
previous &&
previous.id === shrink.id &&
rectsEqual(previous.rect, shrink.rect)
) {
return previous;
}
return shrink;
});
};
const handleGridDragEnter = (event: ReactDragEvent<HTMLDivElement>): void => {
if (event.dataTransfer.types.includes(PALETTE_MIME)) {
dragOverCountRef.current += 1;
}
};
const handleGridDragLeave = (event: ReactDragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes(PALETTE_MIME)) return;
dragOverCountRef.current = Math.max(0, dragOverCountRef.current - 1);
if (dragOverCountRef.current === 0) {
setGhostRect(null);
setShrinkPreview(null);
}
};
/**
* 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 for a drop anywhere over the grid's own rendered rows, resolving
* the same `resolveGridDropPlacement` call the previews it's replacing
* already showed, plus 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").
*
* A `shrink` result commits in two calls, shrink *first* — see
* `DashboardProvider.addBuildingBlock`'s own call to
* `resolveParentCollisions`: with the neighbor already shrunk, the new
* block never overlaps anything when that collision pass runs, so nothing
* is disturbed. Inserting first would overlap the still-wide neighbor and
* get pushed straight down by that same collision rule instead, which
* would permanently destroy the split before it ever rendered. All four
* of the neighbor's own layout fields are pinned, not just `col`/`colSpan`
* — `packChildLayout` re-auto-places any child missing `col`/`row` on
* every render, so a partially-written neighbor would drift apart from
* its new sibling on the very next one.
*
* `readingOrderIndex` is computed against `packed` — the *pre-shrink* map,
* still true to what's on screen at the moment of drop — which is correct
* for all four outcomes (left-split, right-split, push-above, push-below):
* verified case by case when `resolveDropPlacement` itself was written.
*/
const handleGridDrop = (event: ReactDragEvent<HTMLDivElement>): void => {
const type = event.dataTransfer.getData(PALETTE_MIME);
dragOverCountRef.current = 0;
setGhostRect(null);
setShrinkPreview(null);
if (!type) return;
const hoveredContainerId = (
event.target as HTMLElement
).closest<HTMLElement>('[data-container-id]')?.dataset.containerId;
if (hoveredContainerId && hoveredContainerId !== nodeId) {
return;
}
event.preventDefault();
event.stopPropagation();
const { rect, shrink } = resolveGridDropPlacement(event);
const readingOrderIndex = children.findIndex(id => {
const sibling = packed[id];
return (
sibling.y > rect.y || (sibling.y === rect.y && sibling.x >= rect.x)
);
});
const index =
readingOrderIndex === -1 ? children.length : readingOrderIndex;
if (shrink) {
provider.updateLayouts({
[shrink.id]: {
col: shrink.rect.x + 1,
row: shrink.rect.y + 1,
colSpan: shrink.rect.w,
rowSpan: shrink.rect.h,
},
});
}
placeBlockAt(nodeId, type, index, {
col: rect.x + 1,
row: rect.y + 1,
colSpan: rect.w,
rowSpan: rect.h,
});
};
return (
<GridSurface
data-container-id={nodeId}
data-test="grid-container"
// The grid itself (`GridStackContainer`, below) now answers a drop
// anywhere over its own rendered rows — this pair only still fires
// for the leftover space past its last row (the grid is only ever as
// tall as its own `min-height` floor plus whatever content needs, and
// this element can still be taller), 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);
}
}}
>
<GridStackContainer
className="grid-stack"
ref={containerRef}
onDragEnter={handleGridDragEnter}
onDragOver={handleGridDragOver}
onDragLeave={handleGridDragLeave}
onDrop={handleGridDrop}
>
{children.map(childId => (
<GridStackItem
key={childId}
nodeId={childId}
registerItem={registerItem}
unregisterItem={unregisterItem}
/>
))}
{ghostRect &&
(() => {
const containerBox = containerRef.current?.getBoundingClientRect();
const containerWidthPx = containerBox?.width ?? 0;
const cellGeometry = resolveCellGeometry(metrics, containerWidthPx);
const pixelRect = pixelRectForCell(ghostRect, cellGeometry);
return (
<DropGhost
data-test="grid-drop-ghost"
style={{
left: pixelRect.left,
top: pixelRect.top,
width: pixelRect.width,
height: pixelRect.height,
}}
/>
);
})()}
</GridStackContainer>
</GridSurface>
);
}
@@ -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;
}
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
import { blockLabel } from './blockLabel';
beforeAll(() => {
registerBuiltInBuildingBlocks();
});
test('a chart is named by the title its author wrote into the option', () => {
expect(
blockLabel('echarts', {
echartsOptions: { title: { text: 'Sales by Territory' } },
}),
).toBe('Sales by Territory');
});
test('a chart carrying several titles is named by the first', () => {
// ECharts takes one title or a list of them; the first is the chart's and
// the rest annotate parts of it.
expect(
blockLabel('echarts', {
echartsOptions: { title: [{ text: 'Revenue' }, { text: 'Units' }] },
}),
).toBe('Revenue');
});
test('a metric tile is named by the label it displays', () => {
expect(blockLabel('metric-tile', { label: 'Total Revenue' })).toBe(
'Total Revenue',
);
});
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('');
expect(blockLabel('markdown', {})).toBe('');
});
test('a block with no name of its own is named by what it is', () => {
// "Table" says what a block is rather than which one it is — worth little,
// and still better than an empty header.
expect(blockLabel('ag-grid-table', {})).toBe('Table');
expect(blockLabel('echarts', undefined)).toBe('ECharts');
});
test('a name of nothing but spaces is no name', () => {
expect(
blockLabel('echarts', { echartsOptions: { title: { text: ' ' } } }),
).toBe('ECharts');
});
test('a type nothing registered still says something', () => {
// An extension's block whose registration failed, or arrived late.
expect(blockLabel('acme-widget', undefined)).toBe('acme-widget');
});
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview What a block is called, in the one place every panel that
* names one can reach.
*
* A block is named in more than one part of the editor — its own header on
* the canvas, its row in the outline — and those have to agree. A block
* called "Sales by Territory" in one and "ECharts" in the other reads as two
* different blocks.
*/
import { views } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
type Props = Record<string, unknown> | undefined;
/**
* The ECharts option's own title, which is where a chart's name is authored.
*
* ECharts accepts either one title or an array of them; the first is the
* chart's, and any others annotate parts of it.
*/
const echartsTitle = (props: Props): unknown => {
const title = (props?.echartsOptions as { title?: unknown } | undefined)
?.title;
const first = Array.isArray(title) ? title[0] : title;
return (first as { text?: unknown } | undefined)?.text;
};
/**
* Where a block type carries a name of its own, distinct from what it
* renders.
*
* `markdown` is deliberately not here. A chart's title or a tile's label is
* a field the block reads once and renders once — naming the block by it
* and having `ChartBlock` skip drawing its own copy (see `ChartBlock`'s own
* 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> = {
echarts: echartsTitle,
'metric-tile': props => props?.label,
tab: props => props?.label,
collapsible: props => props?.label,
slide: props => props?.label,
};
/**
* 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 — "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, ' ');
}
const registered = views
.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
?.find(view => view.id === type);
return registered?.name ?? type;
}
@@ -0,0 +1,135 @@
/**
* 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 { useEffect, useState } from 'react';
import type { dashboard as dashboardApi } from '@apache-superset/core';
import {
Flex,
Loading,
ThemedAgGridReact,
Typography,
} from '@superset-ui/core/components';
import type { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
import { provider, useDashboardRevision } from '../store';
import { fetchQueryData } from '../chartData';
type DataBindingSpec = dashboardApi.DataBindingSpec;
type DataRow = dashboardApi.DataRow;
// No module registration needed here, unlike `ChartBlock`'s own
// `echarts.use([...])` call — `setupAGGridModules()` already runs
// unconditionally at app bootstrap (see `src/views/App.tsx`), well before
// this (or any other) AG Grid consumer ever renders.
function deriveColumnDefs(columns: string[]): ColDef[] {
return columns.map(field => ({ field, headerName: field }));
}
/**
* The built-in `ag-grid-table` building block — registered like any other
* block (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
* (generic, viz_type-less — see `chartData.ts`) the same way `ChartBlock`
* does, then hands the rows straight to AG Grid via the already-themed
* `ThemedAgGridReact` wrapper. Unlike `echarts`, a table's `rowData`/
* `columnDefs` map directly onto query results with no `$bind`-style
* splicing needed — `columnDefs` can optionally be authored explicitly
* (e.g. for custom headers, formatting, or widths), but when omitted,
* columns are derived one-to-one from the query's own result columns.
*/
export default function AgGridTableBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const [rows, setRows] = useState<DataRow[] | null>(null);
const [columns, setColumns] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const node = provider.getNode(nodeId);
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
const bindingKey = JSON.stringify(dataBinding);
useEffect(() => {
if (!dataBinding) {
setError('This table block has no dataBinding.');
setRows(null);
setColumns(null);
return undefined;
}
let cancelled = false;
setError(null);
setRows(null);
setColumns(null);
fetchQueryData(dataBinding)
.then(result => {
if (!cancelled) {
setRows(result.rows);
setColumns(result.columns);
}
})
.catch(e => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
// dataBinding is a fresh object every render — bindingKey is its stable,
// value-equality-comparable proxy.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bindingKey]);
if (!node) return null;
const columnDefs =
(node.props?.columnDefs as ColDef[] | undefined) ??
(columns ? deriveColumnDefs(columns) : undefined);
return (
<div
style={{
// Fills the box `BuildingBlockView`'s placement wrapper gives this
// 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.
overflow: 'hidden',
}}
>
{error && (
<Flex
align="center"
justify="center"
style={{ width: '100%', height: '100%' }}
>
<Typography.Text type="danger">{error}</Typography.Text>
</Flex>
)}
{!error && !rows && (
<Flex
align="center"
justify="center"
style={{ width: '100%', height: '100%' }}
>
<Loading position="inline-centered" size="s" />
</Flex>
)}
{!error && rows && columnDefs && (
<ThemedAgGridReact rowData={rows} columnDefs={columnDefs} />
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import DashboardProvider from '../DashboardProvider';
import ChartBlock from './ChartBlock';
const mockSetOption = jest.fn();
jest.mock('echarts/core', () => ({
__esModule: true,
use: jest.fn(),
init: jest.fn(() => ({
setOption: mockSetOption,
resize: jest.fn(),
dispose: jest.fn(),
})),
}));
jest.mock('../chartData', () => ({
__esModule: true,
fetchQueryData: jest.fn(async () => ({ rows: [{ x: 'a', y: 1 }] })),
}));
/**
* The stock test double never calls back, so nothing this component draws is
* ever measured. ECharts has no self-sizing — it draws what it is told to
* resize to — so a size has to arrive for the canvas to exist at all.
*/
beforeAll(() => {
window.ResizeObserver = class {
constructor(private callback: ResizeObserverCallback) {}
observe() {
this.callback(
[{ contentRect: { width: 400, height: 300 } } as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
};
});
const provider = DashboardProvider.getInstance();
beforeEach(() => {
provider.reset();
mockSetOption.mockClear();
});
test('a chart does not draw the name its header already carries', async () => {
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'echarts',
props: {
dataBinding: { datasource: 1, columns: ['x'], metrics: [] },
echartsOptions: {
title: { text: 'Sales by Territory' },
series: [{ type: 'bar' }],
},
},
});
render(<ChartBlock nodeId={id} />);
await waitFor(() => expect(mockSetOption).toHaveBeenCalled());
// `blockLabel` reads the title out of this same option to name the block,
// so leaving it here would print the chart's name twice, at two sizes, in
// two places. The rest of the option has to survive untouched.
const [option] = mockSetOption.mock.calls[0];
expect(option).not.toHaveProperty('title');
expect(option).toHaveProperty('series');
});
@@ -0,0 +1,286 @@
/**
* 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 { useEffect, useMemo, useRef, useState } from 'react';
import * as echarts from 'echarts/core';
import type { EChartsCoreOption, ECharts } from 'echarts/core';
import {
BarChart,
BoxplotChart,
CustomChart,
FunnelChart,
GaugeChart,
GraphChart,
HeatmapChart,
LineChart,
PieChart,
RadarChart,
SankeyChart,
ScatterChart,
SunburstChart,
TreeChart,
TreemapChart,
} from 'echarts/charts';
import {
AriaComponent,
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { LabelLayout } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { useTheme } from '@apache-superset/core/theme';
import { Flex, Loading, Typography } from '@superset-ui/core/components';
import { provider, useDashboardRevision } from '../store';
import { fetchQueryData } from '../chartData';
import { resolveBindings } from '../resolveBindings';
type DataBindingSpec = dashboardApi.DataBindingSpec;
type DataRow = dashboardApi.DataRow;
// Registers the renderer plus a broad set of chart/component types, once, at
// module load. Mirrors plugin-chart-echarts's own Echart.tsx registration —
// that component isn't reusable here (not part of the package's public
// exports, and has a Redux dependency this page has no reason to take on),
// and nothing else guarantees these are registered before a chart renders:
// a ChartPlugin's registration is metadata-only, and the real render module
// (with its own `use([...])` call) only loads lazily the first time that
// specific plugin actually renders — which never happens on this page,
// since it bypasses ChartPlugin/SuperChart entirely. AI-authored options can
// use any of these series/component types, hence registering broadly rather
// than guessing which ones this page will need.
echarts.use([
CanvasRenderer,
BarChart,
BoxplotChart,
CustomChart,
FunnelChart,
GaugeChart,
GraphChart,
HeatmapChart,
LineChart,
PieChart,
RadarChart,
SankeyChart,
ScatterChart,
SunburstChart,
TreeChart,
TreemapChart,
AriaComponent,
DataZoomComponent,
GraphicComponent,
GridComponent,
MarkAreaComponent,
MarkLineComponent,
LegendComponent,
ToolboxComponent,
TooltipComponent,
TitleComponent,
VisualMapComponent,
LabelLayout,
]);
/**
* Tracks an element's rendered pixel size — ECharts has no self-sizing (it
* only reacts to explicit `resize({width, height})` calls), so whatever
* 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 `RootGrid`), so there's no case here where a
* measured size is ambiguous or collapses to zero the way an unconstrained
* flex height could.
*/
function useElementSize() {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return undefined;
const observer = new ResizeObserver(([entry]) => {
if (entry) {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, size] as const;
}
/**
* A minimal, self-contained ECharts canvas — deliberately not the
* `<Echart>` wrapper `plugin-chart-echarts` uses internally (that component
* isn't part of the package's public exports, and pulls in a Redux
* dependency this prototype has no reason to take on). The renderer and
* chart/component modules it needs are registered by this module's own
* `use([...])` call above, so this only needs to init/setOption/resize.
*/
function EchartsCanvas({
width,
height,
option,
}: {
width: number;
height: number;
option: EChartsCoreOption;
}) {
const divRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ECharts>();
useEffect(() => {
if (!divRef.current) return undefined;
chartRef.current = echarts.init(divRef.current);
return () => {
chartRef.current?.dispose();
chartRef.current = undefined;
};
}, []);
useEffect(() => {
if (width > 0 && height > 0) {
chartRef.current?.resize({ width, height });
}
}, [width, height]);
useEffect(() => {
// notMerge: true — an AI edit can change series/axis shapes drastically
// between calls (e.g. pie -> bar), so stale config from the previous
// option must not linger.
chartRef.current?.setOption(option, true);
}, [option]);
return <div ref={divRef} style={{ width, height }} />;
}
/**
* The built-in `echarts` building block — registered like any other block
* (see `registerBuiltInBuildingBlocks`). Fetches its `dataBinding`
* (generic, viz_type-less — see `chartData.ts`), resolves any `$bind`
* markers in its `echartsOptions` against the results, and draws the
* result. No `SuperChart`/`ChartPlugin`/`buildQuery`/`transformProps`
* involved — the AI authors close to a real ECharts `option` directly.
*/
export default function ChartBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const theme = useTheme();
const [containerRef, size] = useElementSize();
const [rows, setRows] = useState<DataRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const node = provider.getNode(nodeId);
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
const bindingKey = JSON.stringify(dataBinding);
useEffect(() => {
if (!dataBinding) {
setError('This chart block has no dataBinding.');
setRows(null);
return undefined;
}
let cancelled = false;
setError(null);
setRows(null);
fetchQueryData(dataBinding)
.then(result => {
if (!cancelled) setRows(result.rows);
})
.catch(e => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
// dataBinding is a fresh object every render — bindingKey is its stable,
// value-equality-comparable proxy.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bindingKey]);
const option = useMemo(() => {
if (!rows) return undefined;
const resolved = resolveBindings(
(node?.props?.echartsOptions as Record<string, unknown>) ?? {},
{ rows, theme },
);
// The chart's name is drawn by the block's header, which reads it from
// this same option (see `blockLabel`). Leaving it here too would print it
// twice, at two sizes, in two places — and the header's copy is the one
// that sits where every other block's name sits.
const withoutTitle = { ...resolved };
delete withoutTitle.title;
return withoutTitle;
}, [node?.props?.echartsOptions, rows, theme]);
if (!node) return null;
return (
<div
ref={containerRef}
style={{
// Fills the box `BuildingBlockView`'s placement wrapper gives this
// block — that wrapper is always a definite pixel box (its column
// share of the container's width, its `rowSpan × rowUnit` height),
// so this is never zero or ambiguous.
width: '100%',
height: '100%',
// Surface, border and corners belong to the card `BuildingBlockView`
// draws around this block and the name above it, so that the name is
// inside the frame rather than over it.
overflow: 'hidden',
}}
>
{error && (
<Flex
align="center"
justify="center"
style={{ width: '100%', height: '100%' }}
>
<Typography.Text type="danger">{error}</Typography.Text>
</Flex>
)}
{!error && !option && (
<Flex
align="center"
justify="center"
style={{ width: '100%', height: '100%' }}
>
<Loading position="inline-centered" size="s" />
</Flex>
)}
{!error && option && size.width > 0 && size.height > 0 && (
<EchartsCanvas
width={size.width}
height={size.height}
option={option}
/>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SafeMarkdown } from '@superset-ui/core/components';
import { provider, useDashboardRevision } from '../store';
/**
* The built-in `markdown` building block — registered like any other block
* (see `registerBuiltInBuildingBlocks`). Fills the box `BuildingBlockView`'s
* placement wrapper gives it (`width`/`height: 100%`) rather than resolving
* its own grid placement.
*/
export default function MarkdownBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const node = provider.getNode(nodeId);
if (!node) return null;
return (
<div
style={{
width: '100%',
height: '100%',
// 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',
}}
>
<SafeMarkdown source={String(node.props?.content ?? '')} />
</div>
);
}
@@ -0,0 +1,188 @@
/**
* 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 { useEffect, useState } from 'react';
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { styled, useTheme } from '@apache-superset/core/theme';
import { Flex, Loading, Typography } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { provider, useDashboardRevision } from '../store';
import { fetchQueryData } from '../chartData';
type DataBindingSpec = dashboardApi.DataBindingSpec;
type Theme = ReturnType<typeof useTheme>;
interface DeltaSpec {
value: number;
direction?: 'up' | 'down' | 'flat';
suffix?: string;
}
// The && bumps specificity above antd's own Title margin rules, which a
// plain inline style prop can't override (same trick as
// `DashboardBuilderV2`'s own `HeaderTitle`) — without it the default margin
// throws off vertical centering against the label/delta stacked below it.
const BigNumber = styled(Typography.Title)`
&& {
margin: 0;
line-height: 1.1;
}
`;
function formatNumber(value: unknown, decimals: number): string {
const num = typeof value === 'number' ? value : Number(value);
if (value == null || Number.isNaN(num))
return value == null ? '—' : String(value);
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(num);
}
function DeltaIndicator({ delta, theme }: { delta: DeltaSpec; theme: Theme }) {
const direction =
delta.direction ??
(delta.value > 0 ? 'up' : delta.value < 0 ? 'down' : 'flat');
const color =
direction === 'up'
? theme.colorSuccess
: direction === 'down'
? theme.colorError
: theme.colorTextSecondary;
const Icon =
direction === 'up'
? Icons.CaretUpOutlined
: direction === 'down'
? Icons.CaretDownOutlined
: undefined;
return (
<Flex align="center" gap={4}>
{Icon && <Icon style={{ color }} />}
<Typography.Text style={{ color }}>
{formatNumber(Math.abs(delta.value), 1)}
{delta.suffix ?? ''}
</Typography.Text>
</Flex>
);
}
/**
* The built-in `metric-tile` building block ("big number") — registered
* like any other block (see `registerBuiltInBuildingBlocks`). Fetches its
* `dataBinding` the same generic way `ChartBlock`/`AgGridTableBlock` do, and
* renders the first result row's value directly as text — no ECharts
* gauge/`graphic` text workaround (what an AI reached for before this block
* existed), and no `$bind` splicing, since there's nothing here to splice
* into: the whole point of this block is a single live number.
*
* `dataBinding` is expected to resolve one column (one metric, no
* `dimensions`) — the value shown is always the *first* row's value for
* that column; a tile shows one number, so grouping isn't meaningful here
* the way it is for a chart or table.
*/
export default function MetricTileBlock({ nodeId }: { nodeId: string }) {
useDashboardRevision();
const theme = useTheme();
const [value, setValue] = useState<unknown>(undefined);
const [columnLabel, setColumnLabel] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const node = provider.getNode(nodeId);
const dataBinding = node?.props?.dataBinding as DataBindingSpec | undefined;
const bindingKey = JSON.stringify(dataBinding);
useEffect(() => {
if (!dataBinding) {
setError('This metric tile has no dataBinding.');
setLoaded(false);
return undefined;
}
let cancelled = false;
setError(null);
setLoaded(false);
fetchQueryData(dataBinding)
.then(result => {
if (cancelled) return;
const [column] = result.columns;
setColumnLabel(column ?? null);
setValue(column ? result.rows[0]?.[column] : undefined);
setLoaded(true);
})
.catch(e => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
// dataBinding is a fresh object every render — bindingKey is its stable,
// value-equality-comparable proxy.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bindingKey]);
if (!node) return null;
const decimals = (node.props?.decimals as number | undefined) ?? 0;
const prefix = (node.props?.prefix as string | undefined) ?? '';
const suffix = (node.props?.suffix as string | undefined) ?? '';
const label = (node.props?.label as string | undefined) ?? columnLabel ?? '';
const delta = node.props?.delta as DeltaSpec | undefined;
return (
<Flex
vertical
justify="center"
style={{
// Fills the box `BuildingBlockView`'s placement wrapper gives this
// block — always a definite pixel box, same as `ChartBlock`.
width: '100%',
height: '100%',
// 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',
}}
>
{error && <Typography.Text type="danger">{error}</Typography.Text>}
{!error && !loaded && <Loading position="inline-centered" size="s" />}
{!error && loaded && (
<>
<BigNumber level={2}>
{prefix}
{formatNumber(value, decimals)}
{suffix}
</BigNumber>
{label && (
<Typography.Text
type="secondary"
style={{ marginTop: theme.marginXS }}
>
{label}
</Typography.Text>
)}
{delta && (
<div style={{ marginTop: theme.marginXS }}>
<DeltaIndicator delta={delta} theme={theme} />
</div>
)}
</>
)}
</Flex>
);
}
@@ -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',
);
});
@@ -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>
);
}
@@ -0,0 +1,343 @@
/**
* 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 — this needs to sit inside the block's own box,
* on top of it, without becoming part of what the block itself renders, and
* that slot is the one place that's true for any block, built-in or
* extension-contributed.
*
* A strip along the whole bottom edge rather than a corner square: this
* resizes one axis, not two (a flow's blocks are already full width, so
* 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), which otherwise reads this same
// pointer-down as the start of a drag on that item — the whole
// container moving on the root grid instead of this one block resizing
// inside it. `data-block-resize` is `RootGrid`'s own drag-cancel
// selector's half of the same guard (see `BuildingBlockView`'s
// `data-block-remove`, which exists for the identical reason).
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>
);
}
@@ -0,0 +1,85 @@
/**
* 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 { buildQueryContext, SupersetClient } from '@superset-ui/core';
import type { QueryFormData } from '@superset-ui/core';
import type { dashboard as dashboardApi } from '@apache-superset/core';
type DataBindingSpec = dashboardApi.DataBindingSpec;
type DataRow = dashboardApi.DataRow;
type QueryDataResult = dashboardApi.QueryDataResult;
interface ChartDataResponseResult {
data?: DataRow[];
colnames?: string[];
error?: string | null;
}
// SupersetClient rejects a non-2xx response with the raw, unparsed Response
// object rather than an Error (see parseResponse.ts) — left as-is, a caller
// doing `String(e)` on that gets the useless "[object Response]". This pulls
// the actual `{message}`/`{errors: [...]}` body Superset's API sends back.
async function describeFetchError(e: unknown): Promise<string> {
if (typeof Response !== 'undefined' && e instanceof Response) {
try {
const body = await e.clone().json();
const detail =
body?.message ??
(Array.isArray(body?.errors)
? body.errors.map((err: { message?: string }) => err.message).join('; ')
: undefined);
return detail ? `${e.status} ${e.statusText}: ${detail}` : `${e.status} ${e.statusText}`;
} catch {
return `${e.status} ${e.statusText}`;
}
}
return e instanceof Error ? e.message : String(e);
}
export async function fetchQueryData(
binding: DataBindingSpec,
): Promise<QueryDataResult> {
const formData = {
datasource: `${binding.datasetId}__table`,
metrics: binding.metrics,
groupby: binding.dimensions ?? [],
adhoc_filters: binding.filters ?? [],
row_limit: binding.rowLimit ?? 1000,
result_format: 'json',
result_type: 'full',
} as unknown as QueryFormData;
const queryContext = buildQueryContext(formData);
let json: { result?: ChartDataResponseResult[] } | undefined;
try {
({ json } = await SupersetClient.post({
endpoint: '/api/v1/chart/data',
jsonPayload: queryContext,
}));
} catch (e) {
throw new Error(await describeFetchError(e));
}
const result = json?.result?.[0];
if (!result || result.error) {
throw new Error(result?.error ?? 'Chart data request returned no result');
}
return { columns: result.colnames ?? [], rows: result.data ?? [] };
}
@@ -0,0 +1,358 @@
/**
* 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 { dashboard as dashboardApi } from '@apache-superset/core';
import {
availableDropSpan,
buildOccupancy,
cellKey,
packChildLayout,
resolveDropPlacement,
resolveExplicitCollisions,
type PackedRect,
} from './gridPacking';
type DashboardNode = dashboardApi.DashboardNode;
function nodeMap(nodes: Record<string, Partial<DashboardNode>>) {
return (id: string) =>
nodes[id] ? ({ id, ...nodes[id] } as DashboardNode) : undefined;
}
test('auto-places children top-to-bottom, left-to-right in order, wrapping at the column count', () => {
const getNode = nodeMap({
a: { layout: { colSpan: 12 } },
b: { layout: { colSpan: 6 } },
c: { layout: { colSpan: 6 } },
d: { layout: { colSpan: 12 } },
});
expect(packChildLayout(['a', 'b', 'c', 'd'], 24, getNode)).toEqual({
a: { x: 0, y: 0, w: 12, h: 1 },
b: { x: 12, y: 0, w: 6, h: 1 },
c: { x: 18, y: 0, w: 6, h: 1 },
d: { x: 0, y: 1, w: 12, h: 1 },
});
});
test('defaults an omitted colSpan to the full column count and rowSpan to 1', () => {
const getNode = nodeMap({ a: {}, b: {} });
expect(packChildLayout(['a', 'b'], 24, getNode)).toEqual({
a: { x: 0, y: 0, w: 24, h: 1 },
b: { x: 0, y: 1, w: 24, h: 1 },
});
});
test('reserves cells for explicitly placed children before auto-placing the rest', () => {
const getNode = nodeMap({
hero: { layout: { col: 1, row: 1, colSpan: 8, rowSpan: 2 } },
auto: { layout: { colSpan: 4 } },
});
expect(packChildLayout(['hero', 'auto'], 24, getNode)).toEqual({
hero: { x: 0, y: 0, w: 8, h: 2 },
auto: { x: 8, y: 0, w: 4, h: 1 },
});
});
test('auto-placed children flow around an explicitly placed obstacle', () => {
const getNode = nodeMap({
badge: { layout: { col: 1, row: 1, colSpan: 24 } },
auto: { layout: { colSpan: 24 } },
});
expect(packChildLayout(['badge', 'auto'], 24, getNode)).toEqual({
badge: { x: 0, y: 0, w: 24, h: 1 },
auto: { x: 0, y: 1, w: 24, h: 1 },
});
});
test('clamps a colSpan wider than the container to the column count', () => {
const getNode = nodeMap({ a: { layout: { colSpan: 99 } } });
expect(packChildLayout(['a'], 24, getNode)).toEqual({
a: { x: 0, y: 0, w: 24, h: 1 },
});
});
test('resolveExplicitCollisions leaves non-colliding explicit children untouched', () => {
const getNode = nodeMap({
a: { layout: { col: 1, row: 1, colSpan: 12 } },
b: { layout: { col: 13, row: 1, colSpan: 12 } },
});
expect(resolveExplicitCollisions(['a', 'b'], 24, getNode)).toEqual({});
});
test('resolveExplicitCollisions ignores auto-placed children entirely', () => {
const getNode = nodeMap({
a: { layout: { col: 1, row: 1, colSpan: 24 } },
auto: { layout: { colSpan: 24 } },
});
expect(resolveExplicitCollisions(['a', 'auto'], 24, getNode)).toEqual({});
});
test('resolveExplicitCollisions pushes a later, colliding explicit child straight down', () => {
const getNode = nodeMap({
first: { layout: { col: 1, row: 1, colSpan: 24 } },
second: { layout: { col: 1, row: 1, colSpan: 24 } },
});
expect(resolveExplicitCollisions(['first', 'second'], 24, getNode)).toEqual({
second: { col: 1, row: 2 },
});
});
test('resolveExplicitCollisions cascades past every already-placed row it still overlaps', () => {
const getNode = nodeMap({
first: { layout: { col: 1, row: 1, colSpan: 24 } },
second: { layout: { col: 1, row: 2, colSpan: 24 } },
third: { layout: { col: 1, row: 1, colSpan: 24 } },
});
expect(
resolveExplicitCollisions(['first', 'second', 'third'], 24, getNode),
).toEqual({
third: { col: 1, row: 3 },
});
});
test('resolveExplicitCollisions does not move an explicit child whose column only overlaps a different row', () => {
const getNode = nodeMap({
a: { layout: { col: 1, row: 1, colSpan: 12 } },
b: { layout: { col: 1, row: 2, colSpan: 6 } },
});
expect(resolveExplicitCollisions(['a', 'b'], 24, getNode)).toEqual({});
});
test('cellKey is the same string two equal coordinates produce', () => {
expect(cellKey(3, 5)).toBe(cellKey(3, 5));
expect(cellKey(3, 5)).not.toBe(cellKey(5, 3));
});
test('buildOccupancy records which node owns every cell a rect spans', () => {
const packed: Record<string, PackedRect> = {
a: { x: 0, y: 0, w: 2, h: 2 },
b: { x: 2, y: 0, w: 1, h: 1 },
};
const occupancy = buildOccupancy(packed);
expect(occupancy.get(cellKey(0, 0))).toBe('a');
expect(occupancy.get(cellKey(1, 1))).toBe('a');
expect(occupancy.get(cellKey(2, 0))).toBe('b');
expect(occupancy.get(cellKey(2, 1))).toBeUndefined();
});
test('buildOccupancy is empty for an empty grid', () => {
expect(buildOccupancy({})).toEqual(new Map());
});
test('availableDropSpan caps width at maxColSpan on a wholly empty grid, so a drop there is not forced full-width', () => {
// Regression: before `maxColSpan` existed, an empty grid had nothing
// anywhere to bound a free run, so "open space" always meant "the whole
// row" — the ghost (and the block it produced) ignored the cursor's own
// column entirely. Growth is still left-first, so a cursor this far from
// the left edge exhausts the cap before reaching it.
expect(availableDropSpan({}, 24, 10, 3, 6, 12)).toEqual({
x: 0,
y: 3,
w: 12,
h: 6,
});
});
test('availableDropSpan bounds a free run by the nearest occupied cells on either side, even under a cap wide enough to matter', () => {
const packed: Record<string, PackedRect> = {
left: { x: 0, y: 0, w: 6, h: 1 },
right: { x: 18, y: 0, w: 6, h: 1 },
};
expect(availableDropSpan(packed, 24, 10, 0, 6, 24)).toEqual({
x: 6,
y: 0,
w: 12,
h: 6,
});
});
test('availableDropSpan anchors at the run start even when the cursor sits at the run end', () => {
// Regression: the run [6, 17] is free either way, but before this fixed a
// cursor near its right end returned a rect starting at the cursor's own
// column instead of the run's — wider than the space actually to its right.
const packed: Record<string, PackedRect> = {
left: { x: 0, y: 0, w: 6, h: 1 },
right: { x: 18, y: 0, w: 6, h: 1 },
};
expect(availableDropSpan(packed, 24, 17, 0, 6, 24)).toEqual({
x: 6,
y: 0,
w: 12,
h: 6,
});
});
test('availableDropSpan caps height at maxRowSpan even when more rows are free', () => {
expect(availableDropSpan({}, 24, 0, 0, 4, 24)).toEqual({
x: 0,
y: 0,
w: 24,
h: 4,
});
});
test('availableDropSpan stops growing height at the first occupied row below', () => {
const packed: Record<string, PackedRect> = {
blocker: { x: 0, y: 3, w: 24, h: 1 },
};
expect(availableDropSpan(packed, 24, 0, 0, 6, 24)).toEqual({
x: 0,
y: 0,
w: 24,
h: 3,
});
});
test('availableDropSpan over an occupied cell returns the full row, uncapped by maxColSpan', () => {
const packed: Record<string, PackedRect> = {
a: { x: 0, y: 0, w: 24, h: 2 },
};
// maxColSpan(4) is deliberately narrower than the grid: this band means
// "insert a full-width row here", not "drop a block here", so it stays
// uncapped regardless.
expect(availableDropSpan(packed, 24, 5, 1, 6, 4)).toEqual({
x: 0,
y: 1,
w: 24,
h: 6,
});
});
test('resolveDropPlacement over open space delegates to availableDropSpan', () => {
const packed: Record<string, PackedRect> = {
a: { x: 0, y: 0, w: 12, h: 2 },
};
expect(resolveDropPlacement(packed, 24, 15, 0.5, 6, 24)).toEqual({
rect: availableDropSpan(packed, 24, 15, 0, 6, 24),
});
});
test('resolveDropPlacement in the top band of a block inserts a full-width row above it', () => {
const packed: Record<string, PackedRect> = {
target: { x: 4, y: 2, w: 12, h: 8 },
};
// fracY = (2.5 - 2) / 8 = 0.0625, inside the top 25% band.
expect(resolveDropPlacement(packed, 24, 10, 2.5, 6, 12)).toEqual({
rect: { x: 0, y: 2, w: 24, h: 6 },
});
});
test('resolveDropPlacement in the bottom band of a block inserts a full-width row below it', () => {
const packed: Record<string, PackedRect> = {
target: { x: 4, y: 2, w: 12, h: 8 },
};
// fracY = (9.5 - 2) / 8 = 0.9375, inside the bottom 25% band.
expect(resolveDropPlacement(packed, 24, 10, 9.5, 6, 12)).toEqual({
rect: { x: 0, y: 10, w: 24, h: 6 },
});
});
test('resolveDropPlacement in the middle band, left of center, splits the block and shrinks it to the right half', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 24, h: 4 },
};
// fracY = 0.5, in the middle band; exactCol 6 is left of the block's own
// midpoint (12).
expect(resolveDropPlacement(packed, 24, 6, 2, 6, 12)).toEqual({
rect: { x: 0, y: 0, w: 12, h: 4 },
shrink: { id: 'target', rect: { x: 12, y: 0, w: 12, h: 4 } },
});
});
test('resolveDropPlacement in the middle band, right of center, splits the block and shrinks it to the left half', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 24, h: 4 },
};
expect(resolveDropPlacement(packed, 24, 18, 2, 6, 12)).toEqual({
rect: { x: 12, y: 0, w: 12, h: 4 },
shrink: { id: 'target', rect: { x: 0, y: 0, w: 12, h: 4 } },
});
});
test('resolveDropPlacement gives the odd leftover column to whichever half keeps it', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 5, h: 2 },
};
// w=5 -> newW = floor(5/2) = 2, keepW = 3. exactCol 1 is left of the
// midpoint (2.5), so the new block takes the narrower half.
expect(resolveDropPlacement(packed, 24, 1, 1, 6, 12)).toEqual({
rect: { x: 0, y: 0, w: 2, h: 2 },
shrink: { id: 'target', rect: { x: 2, y: 0, w: 3, h: 2 } },
});
});
test('resolveDropPlacement refuses to split a block narrower than the minimum, falling back to the nearer edge', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 3, h: 2 },
};
// fracY = (0.6 - 0) / 2 = 0.3 is in the middle band (not within
// EDGE_BAND_FRACTION of either edge), but w=3 is below MIN_SPLIT_COLUMNS —
// falls back to the nearer edge, and 0.3 is nearer the top.
expect(resolveDropPlacement(packed, 24, 1, 0.6, 6, 12)).toEqual({
rect: { x: 0, y: 0, w: 24, h: 6 },
});
});
test('resolveDropPlacement never produces a shrink that overlaps an unrelated sibling', () => {
const packed: Record<string, PackedRect> = {
target: { x: 0, y: 0, w: 24, h: 4 },
below: { x: 0, y: 4, w: 24, h: 2 },
};
const { rect, shrink } = resolveDropPlacement(packed, 24, 6, 2, 6, 12);
const overlapsBelow = (r: PackedRect): boolean =>
r.y < packed.below.y + packed.below.h && packed.below.y < r.y + r.h;
expect(overlapsBelow(rect)).toBe(false);
expect(shrink && overlapsBelow(shrink.rect)).toBe(false);
});
test('availableDropSpan follows the cursor within a capped, wide-open run rather than anchoring to a fixed edge', () => {
// No neighbors anywhere in this row: the only bound on either side is
// maxColSpan itself. A cursor near the grid's own right edge should still
// produce a rect near the cursor, not one stuck at column 0.
expect(availableDropSpan({}, 24, 20, 0, 6, 12)).toEqual({
x: 9,
y: 0,
w: 12,
h: 6,
});
});
@@ -0,0 +1,411 @@
/**
* 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 { dashboard as dashboardApi } from '@apache-superset/core';
type DashboardNode = dashboardApi.DashboardNode;
/** A child's resolved position/size, in the grid's own 0-based coordinate convention (shared by every grid engine this module has fed — react-grid-layout and GridStack alike). */
export interface PackedRect {
x: number;
y: number;
w: number;
h: number;
}
/** The occupancy-map key for a single cell — shared by every function below that needs to test or record one, so they all agree on the same convention. */
export function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
/**
* Which node, if any, occupies each cell of `packed` — a lookup `availableDropSpan`
* needs only as a yes/no test, but `resolveDropPlacement` needs to know *which*
* block a drop point falls inside, to find that block's own edges. Built fresh
* from `packed` rather than threaded through as its own parameter, since every
* caller already has `packed` to build it from.
*/
export function buildOccupancy(
packed: Record<string, PackedRect>,
): Map<string, string> {
const occupancy = new Map<string, string>();
Object.entries(packed).forEach(([id, rect]) => {
for (let dy = 0; dy < rect.h; dy += 1) {
for (let dx = 0; dx < rect.w; dx += 1) {
occupancy.set(cellKey(rect.x + dx, rect.y + dy), id);
}
}
});
return occupancy;
}
/**
* Resolves a definite `{x, y, w, h}` for every child of a container, given
* only each child's own `layout` — which, per the dashboard schema, may omit
* `col`/`row` entirely to request auto-placement. The grid engine underneath
* has no "auto" position of its own; every item it's given needs concrete
* coordinates, so this is the one-time translation from "no position
* specified" to "here's where that currently lands."
*
* This runs fresh on every render from the stored layout rather than being
* persisted: a node stays auto-placed, and keeps reflowing around whatever
* its siblings are doing, until something actually moves *it* (a drag, or a
* resize elsewhere that displaces it) — at which point the caller persists
* an explicit `col`/`row` for that one node.
*
* Explicitly placed children reserve their cells first; auto-placed ones
* fill the next open run of cells that fits their span, scanning
* top-to-bottom, left-to-right, in `children` order — the same order that
* governs reading/DOM/tab order, and the same shape a single-column flow's
* own top-to-bottom auto-placement produces.
*/
export function packChildLayout(
children: readonly string[],
columns: number,
getNode: (id: string) => DashboardNode | undefined,
): Record<string, PackedRect> {
const occupied = new Set<string>();
const result: Record<string, PackedRect> = {};
const occupy = (x: number, y: number, w: number, h: number) => {
for (let dy = 0; dy < h; dy += 1) {
for (let dx = 0; dx < w; dx += 1) {
occupied.add(cellKey(x + dx, y + dy));
}
}
};
const fits = (x: number, y: number, w: number, h: number) => {
if (x + w > columns) return false;
for (let dy = 0; dy < h; dy += 1) {
for (let dx = 0; dx < w; dx += 1) {
if (occupied.has(cellKey(x + dx, y + dy))) return false;
}
}
return true;
};
const autoPlaced: { id: string; w: number; h: number }[] = [];
children.forEach(id => {
const layout = getNode(id)?.layout;
const w = Math.min(layout?.colSpan ?? columns, columns);
const h = layout?.rowSpan ?? 1;
if (layout?.col != null && layout?.row != null) {
const x = layout.col - 1;
const y = layout.row - 1;
result[id] = { x, y, w, h };
occupy(x, y, w, h);
} else {
autoPlaced.push({ id, w, h });
}
});
autoPlaced.forEach(({ id, w, h }) => {
let y = 0;
let placed = false;
while (!placed) {
for (let x = 0; x <= columns - w; x += 1) {
if (fits(x, y, w, h)) {
result[id] = { x, y, w, h };
occupy(x, y, w, h);
placed = true;
break;
}
}
y += 1;
}
});
return result;
}
/**
* 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, split out here so it
* can be exercised without a real drag gesture, the same reason
* `packChildLayout`'s own placement math lives here rather than inside a
* component. The one entry point that actually decides between this ("land
* in whatever's already free") and a left/right split of an existing block
* is `resolveDropPlacement`, below, which calls this for its own "open
* space" case.
*
* Open space returns exactly as wide a span as that row has free, capped at
* `columns` and at `maxColSpan` — a wholly empty grid included, since
* nothing anywhere is occupied there either, and without the second cap an
* empty (or otherwise wide-open) grid would offer a full-width block as the
* *only* size a drop could ever produce there, regardless of where the
* cursor actually is. Growth is left-first, same as it was before
* `maxColSpan` existed, with the cap folded into each step rather than
* applied after the fact, so a cursor near either true edge of a bounded
* free run still lands next to that edge instead of centering blindly and
* spilling past it. Height grows 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 minimum width is 1
* column, so a single free column is already as legitimate a place to drop
* one as a whole free row is — narrower than that and there is no width
* left to report at all. The returned rect's own `x` is the free run's own
* left edge, not the cursor's column — a cursor near the right end of a
* free run would otherwise get a preview wider than the space actually to
* its right.
*
* Directly over another block there is no *beside* to speak of here, only
* *above* or *below* it (resolved by vertical collision-avoidance the same
* way repositioning an existing block already is), so that returns the full
* row at `maxRowSpan` instead — deliberately not capped by `maxColSpan`:
* that band means "insert a full-width row here", not "drop a block here".
*/
export function availableDropSpan(
packed: Record<string, PackedRect>,
columns: number,
cursorCol: number,
cursorRow: number,
maxRowSpan: number,
maxColSpan: number,
): PackedRect {
const occupancy = buildOccupancy(packed);
const isOccupied = (x: number, y: number): boolean =>
occupancy.has(cellKey(x, y));
if (isOccupied(cursorCol, cursorRow)) {
return { x: 0, y: cursorRow, w: columns, h: maxRowSpan };
}
let left = cursorCol;
let right = cursorCol;
while (
left > 0 &&
!isOccupied(left - 1, cursorRow) &&
right - (left - 1) + 1 <= maxColSpan
) {
left -= 1;
}
while (
right < columns - 1 &&
!isOccupied(right + 1, cursorRow) &&
right + 1 - left + 1 <= maxColSpan
) {
right += 1;
}
const w = right - left + 1;
const rowIsFree = (row: number): boolean => {
for (let dx = 0; dx < w; dx += 1) {
if (isOccupied(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 { x: left, y: cursorRow, w, h };
}
/**
* How far in from each edge of an occupied block's own height a drop still
* reads as "insert a full-width row above/below it" rather than "split it
* left/right" — the band `resolveDropPlacement` checks before considering a
* split at all, so that inserting a full-width row between two existing
* blocks stays reachable once splitting exists (without it, *any* drop onto
* a block would try to split it, and there would be no way left to ask for
* a plain row above or below one).
*/
const EDGE_BAND_FRACTION = 0.25;
/**
* How many columns a block must span before splitting it is worth
* offering. `resolveDropPlacement` halves a target's width with
* `Math.floor(w / 2)`; below this, one of the two resulting halves would be
* a sliver too thin to be its own block — narrower than this and the drop
* falls back to the same nearer-edge full-row behavior a top/bottom-band
* drop already gets.
*/
const MIN_SPLIT_COLUMNS = 4;
/**
* What a drop resolves to: the new block's own rect, and — only when it
* lands as a left/right split of an existing block — that block's own
* shrunk rect. `RootGrid`'s own live preview and its actual drop handler
* both call `resolveDropPlacement` with the same inputs, so what an author
* sees while hovering is provably what they get on release.
*/
export interface DropPlacement {
rect: PackedRect;
shrink?: { id: string; rect: PackedRect };
}
/**
* Resolves a drop at the cursor's own fractional position — `exactCol`/
* `exactRow`, not yet floored to a cell, since a left/right split needs the
* fraction to tell which half of the target block the cursor is actually
* nearer to, not just which cell it's over.
*
* Landing in open space delegates entirely to `availableDropSpan`, above —
* unchanged from before this function existed. Landing on an existing
* block is otherwise a plain insert (`compactType`-style push, the same
* "displace, never overlap" rule `resolveExplicitCollisions` already
* enforces) within `EDGE_BAND_FRACTION` of its top or bottom edge, or a
* split — new block takes whichever half of the *block's own* width
* (`target.x + target.w / 2`, not the cell the cursor happens to be over)
* it's nearer to, spanning the target's exact row range; the target shrinks
* to the other half, keeping whichever side has the odd leftover column —
* everywhere in between.
*
* A split result is collision-free by construction: the new block only
* ever occupies columns strictly inside the target's own rectangle, which
* `packChildLayout` already guarantees nothing else in `packed` overlaps.
* That's also why `resolveExplicitCollisions` never needs to run again
* after one — there is nothing left for it to find.
*/
export function resolveDropPlacement(
packed: Record<string, PackedRect>,
columns: number,
exactCol: number,
exactRow: number,
maxRowSpan: number,
maxColSpan: number,
): DropPlacement {
const cursorCol = Math.floor(exactCol);
const cursorRow = Math.floor(exactRow);
const occupancy = buildOccupancy(packed);
const hitId = occupancy.get(cellKey(cursorCol, cursorRow));
if (hitId === undefined) {
return {
rect: availableDropSpan(
packed,
columns,
cursorCol,
cursorRow,
maxRowSpan,
maxColSpan,
),
};
}
const target = packed[hitId];
const fracY = (exactRow - target.y) / target.h;
const insertAbove: DropPlacement = {
rect: { x: 0, y: target.y, w: columns, h: maxRowSpan },
};
const insertBelow: DropPlacement = {
rect: { x: 0, y: target.y + target.h, w: columns, h: maxRowSpan },
};
if (fracY < EDGE_BAND_FRACTION) return insertAbove;
if (fracY > 1 - EDGE_BAND_FRACTION) return insertBelow;
if (target.w < MIN_SPLIT_COLUMNS) {
return fracY < 0.5 ? insertAbove : insertBelow;
}
const newW = Math.floor(target.w / 2);
const keepW = target.w - newW;
const takesLeftHalf = exactCol < target.x + target.w / 2;
if (takesLeftHalf) {
return {
rect: { x: target.x, y: target.y, w: newW, h: target.h },
shrink: {
id: hitId,
rect: { x: target.x + newW, y: target.y, w: keepW, h: target.h },
},
};
}
return {
rect: { x: target.x + keepW, y: target.y, w: newW, h: target.h },
shrink: {
id: hitId,
rect: { x: target.x, y: target.y, w: keepW, h: target.h },
},
};
}
function rectsOverlap(
a: { col: number; row: number; colSpan: number; rowSpan: number },
b: { col: number; row: number; colSpan: number; rowSpan: number },
): boolean {
return (
a.col < b.col + b.colSpan &&
b.col < a.col + a.colSpan &&
a.row < b.row + b.rowSpan &&
b.row < a.row + a.rowSpan
);
}
/**
* Resolves collisions among a container's *explicitly* placed children
* (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 on the
* root's own grid (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 driven by a mouse. The
* one place anything *does* get shrunk instead of displaced is a left/right
* split (`resolveDropPlacement`) — and even there, only on an explicit
* author gesture, at drop time, never as a side effect of resolving someone
* else's collision the way this function does.
*
* Auto-placed children (`col`/`row` omitted) are skipped entirely — they
* have no fixed position to resolve; they flow around whatever's explicit
* at render time instead (see `packChildLayout`).
*
* Returns only the children whose position actually needed to change, in
* `{col, row}` form ready for `DashboardProvider.updateLayouts`.
*/
export function resolveExplicitCollisions(
children: readonly string[],
columns: number,
getNode: (id: string) => DashboardNode | undefined,
): Record<string, { col: number; row: number }> {
const placed: {
col: number;
row: number;
colSpan: number;
rowSpan: number;
}[] = [];
const adjustments: Record<string, { col: number; row: number }> = {};
children.forEach(id => {
const layout = getNode(id)?.layout;
if (layout?.col == null || layout?.row == null) return;
const rect = {
col: layout.col,
row: layout.row,
colSpan: Math.min(layout.colSpan ?? columns, columns),
rowSpan: layout.rowSpan ?? 1,
};
while (placed.some(other => rectsOverlap(other, rect))) {
rect.row += 1;
}
if (rect.row !== layout.row) {
adjustments[id] = { col: rect.col, row: rect.row };
}
placed.push(rect);
});
return adjustments;
}
@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host implementation of the `dashboard` contribution type
* (prototype). Extensions/the AI agent call the public `dashboard.*` API
* (`@apache-superset/core`) to place/move/resize/remove nodes; the host owns
* the single in-memory node tree backing the "Dashboard v2" prototype page.
*
* The public namespace (`dashboard`) is exposed to extensions on
* `window.superset`. `useDashboardRevision` is host-internal and NOT part of
* the public `@apache-superset/core` API — it's how the prototype's own
* canvas renderer knows to re-render, then walks the tree via the same
* `getRoot`/`getNode` accessors extensions use.
*/
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { provider, useDashboardRevision } from './store';
import { fetchQueryData } from './chartData';
import { registerBuiltInBuildingBlocks } from './registerBuiltInBuildingBlocks';
// Built-in block types (canvas/markdown/echarts) are registered the same
// way an extension registers its own — see registerBuiltInBuildingBlocks.
// Doing this here guarantees it happens before anything imports `dashboard`
// to render a node, regardless of which page or bridge triggers the import.
registerBuiltInBuildingBlocks();
export { useDashboardRevision };
export const dashboard: typeof dashboardApi = {
getRoot: provider.getRoot,
getNode: provider.getNode,
addBuildingBlock: provider.addBuildingBlock.bind(provider),
removeBuildingBlock: provider.removeBuildingBlock.bind(provider),
moveBuildingBlock: provider.moveBuildingBlock.bind(provider),
updateLayout: provider.updateLayout.bind(provider),
updateProps: provider.updateProps.bind(provider),
onDidLayoutChange: provider.onDidLayoutChange,
fetchQueryData,
};
@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { supersetTheme } from '@apache-superset/core/theme';
import {
cellAtPoint,
pixelRectForCell,
resolveCellGeometry,
resolveGridMetrics,
} from './layoutStyle';
const theme = supersetTheme as unknown as Parameters<
typeof resolveGridMetrics
>[1];
test('a container with no layout still resolves default grid metrics', () => {
expect(resolveGridMetrics(undefined, theme)).toEqual({
columns: 24,
gap: 16,
rowUnitPx: theme.sizeUnit * 8,
});
});
test('a container names its own columns, gap and row height', () => {
expect(
resolveGridMetrics({ columns: 12, gap: 24, rowUnit: 40 }, theme),
).toEqual({ columns: 12, gap: 24, rowUnitPx: 40 });
});
test('resolveCellGeometry divides the container width evenly across columns, and halves gap into a per-side margin', () => {
expect(
resolveCellGeometry({ columns: 24, gap: 16, rowUnitPx: 32 }, 1200),
).toEqual({ columnWidthPx: 50, cellHeightPx: 48, marginPx: 8 });
});
test('cellAtPoint converts a pixel offset into the fractional column/row it falls on', () => {
const cell = resolveCellGeometry(
{ columns: 24, gap: 16, rowUnitPx: 32 },
1200,
);
expect(cellAtPoint(125, 96, cell)).toEqual({ col: 2.5, row: 2 });
});
test('pixelRectForCell insets each side by half of gap, so two adjacent cells read as gap apart', () => {
const cell = resolveCellGeometry(
{ columns: 24, gap: 16, rowUnitPx: 32 },
1200,
);
const left = pixelRectForCell({ x: 0, y: 0, w: 2, h: 1 }, cell);
const right = pixelRectForCell({ x: 2, y: 0, w: 2, h: 1 }, cell);
expect(left).toEqual({ left: 8, top: 8, width: 84, height: 32 });
expect(right).toEqual({ left: 108, top: 8, width: 84, height: 32 });
// left's own right edge to right's own left edge is exactly `gap` apart.
expect(right.left - (left.left + left.width)).toBe(16);
});
test('pixelRectForCell matches the height react-grid-layout used to produce, for the same rowUnit/gap', () => {
const cell = resolveCellGeometry(
{ columns: 24, gap: 16, rowUnitPx: 32 },
1200,
);
// react-grid-layout's own formula was h*rowUnitPx + (h-1)*gap.
const h = 8;
const rglHeight = h * 32 + (h - 1) * 16;
expect(pixelRectForCell({ x: 0, y: 0, w: 1, h }, cell).height).toBe(
rglHeight,
);
});
@@ -0,0 +1,117 @@
/**
* 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 { dashboard as dashboardApi } from '@apache-superset/core';
import type { useTheme } from '@apache-superset/core/theme';
import type { PackedRect } from './gridPacking';
type LayoutProps = dashboardApi.LayoutProps;
type Theme = ReturnType<typeof useTheme>;
/** Column count a container falls back to when its layout omits `columns`. */
export const DEFAULT_COLUMNS = 24;
const DEFAULT_GAP = 16;
/** A container's resolved grid geometry, in the plain numbers `RootGrid` feeds to the grid engine (column count / row pixel height / gap). */
export interface GridMetrics {
columns: number;
gap: number;
rowUnitPx: number;
}
/**
* Resolves a container's grid geometry, applying the same defaults every
* consumer of a node's `layout` needs to agree on — `rowUnit` falls back to
* a size derived from the theme rather than a bare literal, since it's meant
* to track the app's own spacing scale, not an arbitrary pixel value.
*/
export function resolveGridMetrics(
layout: LayoutProps | undefined,
theme: Theme,
): GridMetrics {
return {
columns: layout?.columns ?? DEFAULT_COLUMNS,
gap: layout?.gap ?? DEFAULT_GAP,
rowUnitPx: layout?.rowUnit ?? theme.sizeUnit * 8,
};
}
/**
* `GridMetrics` translated into actual on-screen pixels for one cell —
* `columnWidthPx` depends on the container's own rendered width (columns are
* fractional tracks, so this can't be resolved from `GridMetrics` alone),
* `cellHeightPx` is a fixed row-track-plus-gap height, and `marginPx` is
* half of `gap`: the grid engine insets a cell's own content by its margin
* on *every* side, so two adjacent cells — each contributing one inset —
* end up `gap` apart in total, not `2 * gap`.
*/
export interface CellGeometry {
columnWidthPx: number;
cellHeightPx: number;
marginPx: number;
}
/** Resolves one cell's actual pixel dimensions from a container's grid metrics and its current rendered width. */
export function resolveCellGeometry(
metrics: GridMetrics,
containerWidthPx: number,
): CellGeometry {
return {
columnWidthPx: containerWidthPx / metrics.columns,
cellHeightPx: metrics.rowUnitPx + metrics.gap,
marginPx: metrics.gap / 2,
};
}
/**
* Converts a pointer position — already relative to the grid container's
* own top-left corner, in pixels — into the fractional column/row it falls
* on. Fractional, not floored: `resolveDropPlacement` (`gridPacking.ts`)
* needs the fraction to tell which half of a target block the pointer is
* actually nearer to, not just which cell it's over.
*/
export function cellAtPoint(
offsetXPx: number,
offsetYPx: number,
cell: CellGeometry,
): { col: number; row: number } {
return {
col: offsetXPx / cell.columnWidthPx,
row: offsetYPx / cell.cellHeightPx,
};
}
/**
* The on-screen pixel rect a `PackedRect` occupies — the inverse of
* `cellAtPoint`, and the other half of the same cell geometry every grid
* item is positioned with (see `CellGeometry`'s own doc comment for the
* margin/2 reasoning). The drop ghost is the one thing here that needs
* this: it isn't a real grid widget, so nothing positions it but this.
*/
export function pixelRectForCell(
rect: PackedRect,
cell: CellGeometry,
): { left: number; top: number; width: number; height: number } {
return {
left: rect.x * cell.columnWidthPx + cell.marginPx,
top: rect.y * cell.cellHeightPx + cell.marginPx,
width: rect.w * cell.columnWidthPx - 2 * cell.marginPx,
height: rect.h * cell.cellHeightPx - 2 * cell.marginPx,
};
}
@@ -0,0 +1,176 @@
/**
* 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 Placing a new block, in the one place both ways of asking
* for it can reach.
*
* A block arrives on a dashboard two ways — clicked in the palette, or
* dragged from it onto a container — and they must produce the same node. Two
* copies of "what a freshly placed block looks like" is how a block dropped
* into a section ends up subtly different from the same block clicked into
* it, and the difference is invisible until someone hits it.
*/
import { GRID_TYPE, isContainerType } from './DashboardProvider';
import { DEFAULT_COLUMNS } from './layoutStyle';
import { provider } from './store';
/**
* What a palette drag carries.
*
* A private type rather than `text/plain` so a drop of anything else — a
* file, a selection of text, a drag from another application — is not read
* as a request to place a block.
*/
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;
/**
* The widest an open-space palette drop is ever allowed to preview or land
* at, in columns (half of {@link DEFAULT_COLUMNS}) — `availableDropSpan`'s
* own `maxColSpan`. Without a cap, "open space" includes a wholly empty
* grid, where nothing anywhere is occupied and the free run in any row
* spans every column there is — so a drop there would always be full-width,
* regardless of where the cursor actually sits, which reads as the ghost
* (and the block it produces) ignoring the drag entirely rather than
* following it. A real gap between two existing blocks narrower than this
* is untouched by it — `availableDropSpan` still returns exactly that gap's
* own width, never wider.
*/
export const FALLBACK_COL_SPAN = Math.floor(DEFAULT_COLUMNS / 2);
/**
* Places a new block of `type` at the end of `parentId`'s children and
* selects it, returning its id.
*
* A container arrives with the grid every other container defaults to, so a
* nested canvas is usable the moment it lands rather than needing its columns
* 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: `RootGrid` ran `availableDropSpan`
* live, during the drag itself, to draw the drop's own live preview — the
* same collision-aware placement a repositioning drag gets from the grid
* engine itself — so nothing here recomputes a position, only writes the
* one already shown as that 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,
layout: isContainerType(type)
? { columns: DEFAULT_COLUMNS, gap: 16, ...position }
: { ...position },
});
provider.setSelection(id);
return id;
}
@@ -0,0 +1,127 @@
/**
* 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 { views } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from './resolveBuildingBlockView';
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 — 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: 'markdown',
name: 'Markdown',
description: 'Renders Markdown content.',
},
DASHBOARD_BUILDING_BLOCKS_LOCATION,
MarkdownBlock,
);
views.registerView(
{
id: 'echarts',
name: 'ECharts',
description: 'Renders a chart from an ECharts option object.',
},
DASHBOARD_BUILDING_BLOCKS_LOCATION,
ChartBlock,
);
views.registerView(
{
id: 'ag-grid-table',
name: 'Table',
description: 'Renders query results as an AG Grid table.',
},
DASHBOARD_BUILDING_BLOCKS_LOCATION,
AgGridTableBlock,
);
views.registerView(
{
id: 'metric-tile',
name: 'Metric Tile',
description: 'Renders a single live metric value as a "big number".',
},
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);
}
@@ -0,0 +1,184 @@
/**
* 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 { dashboard as dashboardApi } from '@apache-superset/core';
import type { useTheme } from '@apache-superset/core/theme';
type DataRow = dashboardApi.DataRow;
type Theme = ReturnType<typeof useTheme>;
export interface BindContext {
rows: DataRow[];
theme: Theme;
}
interface BindMarker {
$bind: {
source: 'metric' | 'dimension' | 'theme' | 'records';
/** `metric`/`dimension` — the row field to pull one column's values from. */
alias?: string;
/** `theme` — the theme token to substitute. */
token?: string;
/**
* `records` — zip several row fields into one array of plain objects,
* one per row: `{ [outputKey]: row[columnAlias], ... }`. This is the
* shape ECharts wants for e.g. pie's `series[].data` (`{name, value}`
* pairs), which a single flat column can't express on its own.
*/
fields?: Record<string, string>;
/**
* `metric`/`dimension` only — return just the first row's value (a
* scalar) instead of an array of every row's value. A query with no
* `dimensions` (a single aggregate — the "big number"/gauge case) only
* ever has one row, but `metric`/`dimension` still resolve to a
* one-element ARRAY by default, since that's what every *other* chart
* shape (bar/line/pie, one point per row) needs. Anywhere ECharts wants
* a plain number/string instead — a gauge's `series[].data[].value`, a
* `graphic[].style.text` label — set `single: true` to get that one
* value unwrapped, rather than `[value]`.
*/
single?: boolean;
};
}
const BIND_SOURCES = new Set(['metric', 'dimension', 'theme', 'records']);
function isBindMarker(value: unknown): value is BindMarker {
return (
typeof value === 'object' &&
value !== null &&
'$bind' in (value as Record<string, unknown>)
);
}
/**
* True for the mistake of writing a `$bind` marker's *inner* shape —
* `{"source": "records", "fields": {...}}` — directly in place of a value,
* omitting the `"$bind"` wrapper key itself. Easy to make (the inner shape
* is what everything actually reads), and costly to miss: unwrapped, it
* isn't a bind marker at all as far as `resolveValue` is concerned, so it
* passes straight through as a literal object — a hard crash later if the
* spot expected an array (e.g. `series[].data`), or a silently wrong value
* if it didn't (e.g. a theme color quietly becoming `{source, token}`
* instead of the color string it names).
*/
function looksLikeUnwrappedBind(
value: Record<string, unknown>,
): value is BindMarker['$bind'] {
return typeof value.source === 'string' && BIND_SOURCES.has(value.source);
}
/**
* A malformed `$bind` marker (a missing `alias`/`fields`/`token`, or an
* unrecognized `source`) used to resolve to `undefined` — which, spliced
* into e.g. a chart's `series[].data`, doesn't fail here at all. It fails
* much later, inside ECharts' own `setOption`, as a generic
* "series.data ... must be an array" console error with no indication that
* the actual cause was an incomplete `$bind` several layers up. Throwing
* here instead — during `resolveBindings`, called from `ChartBlock`'s
* render — gets caught by the `ErrorBoundary` already wrapping every block
* (see `BuildingBlockView`) and reported as this specific block's error,
* naming the exact marker that was incomplete.
*/
function resolveBind(bind: BindMarker['$bind'], ctx: BindContext): unknown {
if (bind.source === 'theme') {
if (!bind.token) {
throw new Error('$bind with source "theme" is missing "token"');
}
return (ctx.theme as unknown as Record<string, unknown>)[bind.token];
}
if (bind.source === 'metric' || bind.source === 'dimension') {
if (!bind.alias) {
throw new Error(`$bind with source "${bind.source}" is missing "alias"`);
}
const values = ctx.rows.map(row => row[bind.alias as string]);
return bind.single ? values[0] : values;
}
if (bind.source === 'records') {
if (!bind.fields || Object.keys(bind.fields).length === 0) {
throw new Error('$bind with source "records" is missing "fields"');
}
const { fields } = bind;
return ctx.rows.map(row => {
const record: Record<string, unknown> = {};
Object.entries(fields).forEach(([outputKey, columnAlias]) => {
record[outputKey] = row[columnAlias];
});
return record;
});
}
throw new Error(
`Unknown $bind source: "${(bind as { source: string }).source}"`,
);
}
// ECharts option keys documented as accepting *only* a JavaScript function
// (no string-template alternative the way `formatter` has) — a JSON
// tool-call argument can never supply a function, so any value found under
// one of these keys is unconditionally wrong, not just wrong in some cases.
// Left unchecked, this fails deep inside ECharts' own `setOption` as e.g.
// "valueFormatter is not a function," with nothing pointing back at the
// AI-authored option key that caused it.
const FUNCTION_ONLY_KEYS = new Set(['valueFormatter', 'labelLayout']);
function resolveValue(value: unknown, ctx: BindContext): unknown {
if (Array.isArray(value)) {
return value.map(item => resolveValue(item, ctx));
}
if (isBindMarker(value)) {
return resolveBind(value.$bind, ctx);
}
if (value !== null && typeof value === 'object') {
if (looksLikeUnwrappedBind(value as Record<string, unknown>)) {
throw new Error(
`Found a $bind object without its "$bind" wrapper: ${JSON.stringify(value)}` +
`did you mean {"$bind": ${JSON.stringify(value)}}?`,
);
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, v]) => {
if (FUNCTION_ONLY_KEYS.has(key)) {
throw new Error(
`"${key}" must be a JavaScript function in ECharts, which a JSON-authored ` +
'option can never provide. Remove it and use a string-template field ' +
'instead where one exists (e.g. "formatter" on tooltip/axisLabel/label, ' +
'not "valueFormatter" — a string like "{b}: ${c}" works directly on ' +
'"formatter").',
);
}
return [key, resolveValue(v, ctx)];
}),
);
}
return value;
}
/**
* Recursively walks a near-raw ECharts `option` object and replaces every
* `{"$bind": {...}}` marker with the real value it references — query
* results or a theme token (decision 12 of the design doc's unified `$bind`
* construct). Everything else in the tree passes through unchanged, so an
* AI-authored option can mix literal ECharts config with bound values
* anywhere a literal would otherwise go.
*/
export function resolveBindings(
echartsOptions: Record<string, unknown>,
ctx: BindContext,
): Record<string, unknown> {
return resolveValue(echartsOptions, ctx) as Record<string, unknown>;
}
@@ -0,0 +1,51 @@
/**
* 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 { resolveView, views } from 'src/core/views';
/**
* The `views` location a dashboard node's `type` must be registered at to
* be renderable as a building block — built-in types (markdown/echarts/...,
* see `registerBuiltInBuildingBlocks`) and extension-contributed ones
* 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';
/**
* Resolves a node's registered view, scoped to
* `DASHBOARD_BUILDING_BLOCKS_LOCATION` — `resolveView` alone resolves by id
* only, ignoring location, so without this check a node whose `type`
* happened to collide with some unrelated view id registered elsewhere in
* the app could render the wrong thing. Returns undefined if no building
* block is registered for `type`, so the caller can fall back to an
* "unsupported" placeholder.
*/
export function resolveBuildingBlockView(
type: string,
nodeId: string,
): ReactElement | undefined {
const isRegistered = views
.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
?.some(view => view.id === type);
if (!isRegistered) return undefined;
return resolveView(type, { nodeId });
}
@@ -0,0 +1,37 @@
/**
* 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 Leaf module wrapping the `DashboardProvider` singleton.
*
* Building block components (built-in or extension-contributed) read from
* `provider` and subscribe via `useDashboardRevision` directly — importing
* from here rather than from `./index` avoids a cycle, since `./index` is
* what registers the built-in blocks (which import the provider) in the
* first place.
*/
import { useSyncExternalStore } from 'react';
import DashboardProvider from './DashboardProvider';
export const provider = DashboardProvider.getInstance();
/** Ticks on every dashboard.* mutation so a subscribed component re-reads the tree. */
export const useDashboardRevision = () =>
useSyncExternalStore(provider.subscribe, provider.getRevision);
@@ -0,0 +1,303 @@
/**
* 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 The one place `gridstack` itself is ever imported. `RootGrid`
* owns the dashboard-specific meaning of a gesture (committing a layout,
* detecting a reparent, previewing a palette drop) — this hook owns only the
* mechanics of keeping a `GridStack` instance, its DOM, and this app's own
* `Record<string, PackedRect>` in agreement with each other.
*
* Two rules make a React-owned child list and a GridStack-owned DOM position
* coexist safely, threaded through everything below:
*
* 1. Only `syncWidgets` (the effect keyed on `packed`) ever calls
* `makeWidget`/`update`, and only when the two actually disagree. Nothing
* here ever listens for GridStack's own `'change'` event — that fires for
* `syncWidgets`'s own writes too, and committing from it would be a
* write loop: commit → revision tick → re-render → `syncWidgets` writes
* into GridStack → `'change'` fires → commit again.
* 2. `registerItem`/`unregisterItem` only ever record *which* element
* belongs to *which* node id — they never call a GridStack method
* directly (except `removeWidget` on the way out, where there is no
* "sync later" to defer to). `syncWidgets` is the only thing that ever
* tells GridStack about an element for the first time, which is what
* makes the ordering in `RootGrid`'s own `GridStackItem` safe regardless
* of whether a child's own mount effect runs before this hook's init
* effect has actually created a `GridStack` instance yet — see
* `syncWidgets`'s own comment.
*/
import { useCallback, useEffect, useRef } from 'react';
import type { RefObject } from 'react';
import { GridStack } from 'gridstack';
import type { GridItemHTMLElement, GridStackNode } from 'gridstack';
import type { PackedRect } from './gridPacking';
import type { GridMetrics } from './layoutStyle';
/** One child's settled position once a drag or resize gesture ends — `RootGrid`'s own counterpart to react-grid-layout's `Layout` array. */
export interface GestureEndItem {
id: string;
rect: PackedRect;
}
/** Which gesture just ended, on which node, and the element it ended on — enough for `RootGrid` to decide for itself whether this was a plain resize/reposition or a drag that landed in a different container (see `findContainerIdAt`, which this hook has no reason to know about). */
export interface GestureEnd {
kind: 'drag' | 'resize';
id: string;
el: GridItemHTMLElement;
/**
* Where the pointer actually was when the gesture ended, in viewport
* pixels — GridStack's own `dd-draggable`/`dd-resizable` copy these off
* the underlying native mouseup (`Utils.initEvent`), so they're reliably
* present for a real mouse or touch gesture. `RootGrid` needs this to
* tell whether a *drag* (not a resize) ended on another sibling's own
* left/right split band — the same question `resolveDropPlacement`
* already answers for a palette drop, asked here against the cursor's
* final position rather than a `DragEvent`'s.
*/
clientX: number;
clientY: number;
}
export interface UseGridStackArgs {
metrics: GridMetrics;
packed: Record<string, PackedRect>;
/** The `draggableCancel`-equivalent selector — regions a press must never start a drag from (the remove button, a nested container, a flow resize grip, a header control). Threaded in rather than hardcoded here, since it's dashboard markup this hook has no other reason to know about. */
cancelSelector: string;
onGestureStart?: (kind: 'drag' | 'resize', id: string) => void;
onGestureEnd: (items: GestureEndItem[], gesture: GestureEnd) => void;
}
export interface UseGridStackResult {
/**
* The `.grid-stack` element itself — pass directly as `ref={containerRef}`
* on the container div, and read `containerRef.current` anywhere this
* hook's own caller needs the live DOM node (`RootGrid`'s own drop-preview
* reads it every render, to measure the container's current width and turn
* a cell rect into a pixel one via `pixelRectForCell`). A plain
* `RefObject`, not a callback: a callback ref has no `.current` of its own
* to read, and casting one to something that pretends it does reads
* `undefined` forever — silently, with no crash and no lint error — which
* is exactly the bug that once made the drop-preview's width collapse to
* its own border while its height (fixed pixels, independent of container
* width) kept looking fine. Exposing the ref object itself, rather than a
* callback plus a separate getter for the same node, removes that whole
* failure mode by construction: there is only one way to reach this DOM
* node from outside the hook, and it behaves exactly like every other
* `RefObject` in React.
*/
containerRef: RefObject<HTMLDivElement>;
/** Call from a grid item's own mount — see `RootGrid`'s `GridStackItem`. */
registerItem: (id: string, el: HTMLDivElement) => void;
/** Call from a grid item's own unmount cleanup, before it detaches. */
unregisterItem: (id: string) => void;
}
function readGestureItems(grid: GridStack): GestureEndItem[] {
return grid
.getGridItems()
.map(el => el.gridstackNode)
.filter((node): node is GridStackNode => !!node?.id)
.map(node => ({
id: node.id as string,
rect: { x: node.x ?? 0, y: node.y ?? 0, w: node.w ?? 1, h: node.h ?? 1 },
}));
}
export function useGridStack({
metrics,
packed,
cancelSelector,
onGestureStart,
onGestureEnd,
}: UseGridStackArgs): UseGridStackResult {
const containerRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<GridStack | null>(null);
const itemElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
// Read by the `dragstart`/`dragstop`/etc. listeners the init effect below
// registers once — they're never torn down and re-attached per render, so
// they need whichever callback is *currently* current, not whichever one
// was passed in on the render that created them.
const onGestureStartRef = useRef(onGestureStart);
onGestureStartRef.current = onGestureStart;
const onGestureEndRef = useRef(onGestureEnd);
onGestureEndRef.current = onGestureEnd;
// `useCallback`'d with an empty dep array — they only ever touch refs,
// never a render-scoped value — so a consumer (`GridStackItem`) can safely
// list one in its own effect deps without that effect re-firing on every
// unrelated render of whatever's above it.
const registerItem = useCallback((id: string, el: HTMLDivElement): void => {
itemElsRef.current.set(id, el);
}, []);
const unregisterItem = useCallback((id: string): void => {
const el = itemElsRef.current.get(id);
itemElsRef.current.delete(id);
if (el) gridRef.current?.removeWidget(el, false);
}, []);
// Init, once. Deliberately not re-run when `metrics` changes later — a
// later change is applied imperatively (the next effect), never by
// re-initializing, which would throw away every widget's own DOM
// registration.
useEffect(() => {
if (!containerRef.current) return undefined;
const { columns, gap, rowUnitPx } = metrics;
const grid = GridStack.init(
{
column: columns,
cellHeight: rowUnitPx + gap,
margin: gap / 2,
// Widgets never overlap regardless of `float` — this only turns off
// the *second*, library-owned compaction pass that would otherwise
// fight `packChildLayout`'s own auto-placement (already gravity-style,
// computed from the stored data) for the same job. Left open by
// `availableDropSpan`, a gap should stay a gap.
float: true,
// GridStack's own default (`auto: true`) scans the container for
// any `.grid-stack-item` elements *already in the DOM* the moment
// `init` runs and silently registers them itself — reading position
// from `gs-x`/`gs-y`/... attributes we never write and, critically,
// an `id` from a `gs-id` attribute we never write either. React has
// already rendered every initially-mounted `GridStackItem` by the
// time this effect runs (a passive effect always runs after every
// layout effect in the same commit, including the child's own
// `registerItem` one), so without this, every item present at
// mount gets silently claimed with `id: undefined` before
// `syncWidgets` ever gets a chance to call `makeWidget` on it —
// which then makes every gesture-end handler's `!!node?.id` filter
// (`useGridStack`'s `readGestureItems`) drop it, so nothing about it
// is ever committed and it springs back to its last known position
// on the very next drag or resize. `syncWidgets` is the only thing
// that is ever allowed to introduce an element to GridStack (see
// this module's own doc comment) — this is what actually makes
// that true, rather than just documenting an intent GridStack's own
// default quietly undermines.
auto: false,
// The palette's own native HTML5 drag stays exactly as it is —
// GridStack's own drag-in system uses its own pointer-based DD
// engine, not `dataTransfer`, and can't see it. `RootGrid` draws its
// own drop preview instead (see `resolveDropPlacement`).
acceptWidgets: false,
removable: false,
// CSS transitions racing the very next render's own DOM write is one
// more thing to rule out, not something worth animating.
animate: false,
resizable: { handles: 'se, sw, nw, ne' },
draggable: { cancel: cancelSelector },
},
containerRef.current,
);
gridRef.current = grid;
if (!grid) return undefined;
const handleDragStart = (_event: Event, el: GridItemHTMLElement) =>
onGestureStartRef.current?.('drag', el.gridstackNode?.id ?? '');
const handleResizeStart = (_event: Event, el: GridItemHTMLElement) =>
onGestureStartRef.current?.('resize', el.gridstackNode?.id ?? '');
const handleDragStop = (event: Event, el: GridItemHTMLElement) => {
const { clientX = 0, clientY = 0 } = event as unknown as {
clientX?: number;
clientY?: number;
};
onGestureEndRef.current(readGestureItems(grid), {
kind: 'drag',
id: el.gridstackNode?.id ?? '',
el,
clientX,
clientY,
});
};
const handleResizeStop = (event: Event, el: GridItemHTMLElement) => {
const { clientX = 0, clientY = 0 } = event as unknown as {
clientX?: number;
clientY?: number;
};
onGestureEndRef.current(readGestureItems(grid), {
kind: 'resize',
id: el.gridstackNode?.id ?? '',
el,
clientX,
clientY,
});
};
grid.on('dragstart', handleDragStart);
grid.on('resizestart', handleResizeStart);
grid.on('dragstop', handleDragStop);
grid.on('resizestop', handleResizeStop);
return () => {
grid.destroy(false);
gridRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync, whenever `packed` changes — and, since effects within one
// component run in the order they're declared, also once on mount, right
// after the init effect above (regardless of whether any `GridStackItem`
// already registered *before* that init effect ran: a child's own mount
// effect is a layout effect, which the whole subtree runs before any
// component's plain `useEffect` runs at all, so `itemElsRef` is already
// populated the first time this ever executes).
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
grid.batchUpdate();
try {
Object.entries(packed).forEach(([id, rect]) => {
const el = itemElsRef.current.get(id);
if (!el) return;
const node = (el as GridItemHTMLElement).gridstackNode;
if (!node) {
grid.makeWidget(el, { id, ...rect });
} else if (
node.x !== rect.x ||
node.y !== rect.y ||
node.w !== rect.w ||
node.h !== rect.h
) {
grid.update(el, rect);
}
});
} finally {
grid.batchUpdate(false);
}
}, [packed]);
// Metrics change (the root's own columns/gap/rowUnit, edited after the
// grid already exists) — applied imperatively, `'none'` so widgets keep
// exactly the position `packChildLayout` already gave them rather than
// GridStack's own default rescale-on-column-change fighting it.
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
grid.column(metrics.columns, 'none');
grid.margin(metrics.gap / 2);
grid.cellHeight(metrics.rowUnitPx + metrics.gap);
}, [metrics]);
return { containerRef, registerItem, unregisterItem };
}
+1
View File
@@ -29,6 +29,7 @@ export const core: typeof coreType = {
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './dashboard';
export * from './editors';
export * from './extensions';
export * from './menus';
@@ -99,6 +99,12 @@ test('chart and dashboard list pages get their own page types', async () => {
expect(navigation.getPage()).toBe('dashboard_list');
});
test('Dashboard v2 is distinct from the classic dashboard route', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/dashboard/v2/new/');
expect(navigation.getPage()).toBe('dashboard_v2');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/tablemodelview/list/');
@@ -41,6 +41,7 @@ const PAGE_ROUTES: { path: string; page: Page }[] = [
// greedily capture `/dashboard/list/` (idOrSlug='list'), so the more specific
// list route has to win first — mirroring the `routes.tsx` Switch precedence.
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
{ path: RoutePaths.DASHBOARD_V2_NEW, page: 'dashboard_v2' },
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
+7 -4
View File
@@ -37,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
const viewRegistry: Map<
string,
{ view: View; location: string; component: ComponentType }
{ view: View; location: string; component: ComponentType<any> }
> = new Map();
const locationIndex: Map<string, Set<string>> = new Map();
@@ -66,7 +66,7 @@ const notifyUnregister = (event: ViewUnregisteredEvent) => {
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
component: ComponentType,
component: ComponentType<any>,
): Disposable => {
const { id } = view;
@@ -84,7 +84,10 @@ const registerView: typeof viewsApi.registerView = (
});
};
export const resolveView = (id: string): React.ReactElement => {
export const resolveView = (
id: string,
props?: Record<string, unknown>,
): React.ReactElement => {
const entry = viewRegistry.get(id);
if (!entry) {
return React.createElement(ExtensionPlaceholder, { id });
@@ -92,7 +95,7 @@ export const resolveView = (id: string): React.ReactElement => {
return React.createElement(
ErrorBoundary,
null,
React.createElement(entry.component),
React.createElement(entry.component, props),
);
};
@@ -25,6 +25,7 @@ import {
chat,
core,
commands,
dashboard,
editors,
extensions,
menus,
@@ -59,6 +60,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
chat,
core,
commands,
dashboard,
editors,
extensions,
menus,
@@ -31,6 +31,7 @@ import type {
chat,
commands,
core,
dashboard,
editors,
extensions,
menus,
@@ -45,6 +46,7 @@ export interface Namespaces {
core: typeof core;
chat: typeof chat;
commands: typeof commands;
dashboard: typeof dashboard;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
@@ -251,6 +251,19 @@ const RightMenu = ({
perm: 'can_write',
view: 'Dashboard',
},
{
label: t('Dashboard v2'),
// Keep the URL relative so isFrontendRoute() matches and Link navigates
// via React Router — see the SQL query entry's comment above.
url: '/dashboard/v2/new/',
icon: (
<Icons.ThunderboltOutlined
data-test={`menu-item-${t('Dashboard v2')}`}
/>
),
perm: 'can_write',
view: 'Dashboard',
},
];
const checkAllowUploads = () => {
@@ -0,0 +1,151 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import DashboardHeader from './DashboardHeader';
const provider = DashboardProvider.getInstance();
const ADMIN = { userId: 1, firstName: 'Admin', lastName: 'User' };
/** The header reads the session for who is authoring, so it needs the store. */
const renderHeader = () =>
render(<DashboardHeader />, {
useRedux: true,
initialState: { user: ADMIN },
});
beforeEach(() => {
provider.reset();
});
test('the header carries the dashboard-level affordances', () => {
renderHeader();
expect(screen.getByTestId('header-templates')).toBeInTheDocument();
expect(screen.getByTestId('header-history')).toBeInTheDocument();
expect(screen.getByTestId('header-favorite')).toBeInTheDocument();
expect(screen.getByTestId('header-published')).toHaveTextContent('Draft');
expect(screen.getByTestId('header-undo')).toBeInTheDocument();
expect(screen.getByTestId('header-redo')).toBeInTheDocument();
expect(screen.getByTestId('header-save')).toBeInTheDocument();
});
test('everything the builder cannot actually do is disabled, not silently dead', () => {
renderHeader();
// The builder keeps its tree in memory with no dashboard row behind it:
// nothing here can be saved, favourited, published or refreshed, and there
// is no history to step through. A control that looks live and does
// nothing teaches something false about all of them.
[
'header-templates',
'header-history',
'header-favorite',
'header-undo',
'header-redo',
'header-save',
].forEach(test => expect(screen.getByTestId(test)).toBeDisabled());
});
test('the record of what was written sits beside writing it', () => {
renderHeader();
const order = [
...screen.getByTestId('dashboard-header').querySelectorAll('[data-test]'),
].map(el => el.getAttribute('data-test'));
// Saving commits a version; History is the versions already committed.
// They are one concern read in one place, so History leaves the far left —
// where it sat beside Templates as a thing asked before the work — and
// comes to rest immediately before the button that produces what it lists.
expect(order.indexOf('header-history')).toBe(
order.indexOf('header-save') - 1,
);
});
test('how the dashboard is arranged is not asked in the header', () => {
renderHeader();
// Arranging the canvas is authoring, not chrome. It belongs with the rest
// of the root's properties, where the columns and the gap it works with
// already live — see Inspector's Arrangement section.
expect(screen.queryByTestId('layout-mode-switcher')).not.toBeInTheDocument();
});
test('what acts on the canvas is not offered from the bar above it', () => {
renderHeader();
// Arranging and refreshing both act on the canvas as a whole, not on the
// dashboard's identity, so neither belongs in this bar.
expect(screen.queryByTestId('canvas-arrange')).not.toBeInTheDocument();
expect(screen.queryByTestId('header-arrange')).not.toBeInTheDocument();
expect(screen.queryByTestId('header-refresh')).not.toBeInTheDocument();
});
test('the header says who is making the dashboard', () => {
renderHeader();
// The one piece of dashboard metadata this page can state truthfully: a
// dashboard being created is being created by whoever is looking at it.
expect(screen.getByTestId('header-metadata')).toHaveTextContent('Admin User');
});
test('the header does not claim a dashboard with no row behind it was saved', () => {
renderHeader();
// Every other unavailable affordance here says so. A humanized "a day ago"
// beside them would be the only thing on the bar inventing a fact.
expect(screen.getByTestId('header-metadata')).toHaveTextContent(
'Not saved yet',
);
});
test('the dashboard is nameable, and the name is stored on the dashboard', async () => {
renderHeader();
await userEvent.type(screen.getByTestId('header-title'), 'Vaccine rollout');
await userEvent.tab();
// On the root node rather than in this component's state: a name is
// something the dashboard has, so the assistant can read and rename it too.
expect(provider.getRoot().props?.title).toBe('Vaccine rollout');
});
test('the title shows a rename made anywhere else', () => {
provider.updateProps(provider.getRoot().id, { title: 'From the assistant' });
renderHeader();
expect(screen.getByTestId('header-title')).toHaveValue('From the assistant');
});
test('emptying the title is not a rename', async () => {
provider.updateProps(provider.getRoot().id, { title: 'Quarterly review' });
renderHeader();
await userEvent.clear(screen.getByTestId('header-title'));
await userEvent.tab();
// A stray select-all-and-delete must not silently leave the dashboard
// nameless; the field goes back to what the dashboard is still called.
expect(provider.getRoot().props?.title).toBe('Quarterly review');
expect(screen.getByTestId('header-title')).toHaveValue('Quarterly review');
});
@@ -0,0 +1,278 @@
/**
* 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 { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { Divider, Input, PublishedLabel } from '@superset-ui/core/components';
import MetadataBar, {
MetadataType,
} from '@superset-ui/core/components/MetadataBar';
import { Icons } from '@superset-ui/core/components/Icons';
import type { BootstrapUser } from 'src/types/bootstrapTypes';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
import Inert from './InertControl';
/**
* Who is making this dashboard, and when it was last written down.
*
* The two facts a dashboard carries about itself rather than about its
* contents, drawn with the same `MetadataBar` the saved dashboard header
* uses — so a dashboard being built and one being read state them in the
* same shape, with the same icons, in the same place.
*
* Only one of them can be true here. A dashboard being created is being
* created by whoever is looking at it, so the creator is read from the
* session rather than invented. There is no row behind this page and nothing
* has ever been written, so there is no modified time to humanize — and
* "a day ago" beside a Save button that is disabled for having nothing to
* save would be the only thing on this bar stating a fact that is not one.
*/
/**
* The signed-in person's name.
*
* Assembled here rather than through `getUserName`, which reads the
* `first_name`/`last_name` an API hands back for an owner. The session user
* is the same person in a different shape — `firstName`/`lastName` off the
* bootstrap — and passing one to the other returns an empty string rather
* than failing, which is how this first went out reading "Not available"
* over a perfectly well-known name.
*/
const nameOf = (user: BootstrapUser): string =>
[user?.firstName, user?.lastName].filter(Boolean).join(' ') ||
user?.username ||
'';
const Metadata = (): ReactElement => {
const user = useSelector<{ user?: BootstrapUser }, BootstrapUser>(
state => state.user,
);
const author = nameOf(user) || t('Not available');
const unsaved = t('Not saved yet');
return (
<span data-test="header-metadata">
<MetadataBar
tooltipPlacement="bottom"
items={[
{
type: MetadataType.Editor,
createdBy: author,
editors: t('None'),
createdOn: unsaved,
},
{
type: MetadataType.LastModified,
value: unsaved,
modifiedBy: author,
},
]}
/>
</span>
);
};
/**
* The name, drawn as a name rather than as a field.
*
* A bordered box on a bar of small controls read as one more control, and the
* one thing on the bar that says what you are looking at was the hardest thing
* on it to find. Borderless at the heading weight, it reads as the title it
* is; the surface arriving under the pointer and on focus is what still says
* it can be typed into, which is the same trade the editable titles elsewhere
* in the app make.
*/
const TitleInput = styled(Input)`
${({ theme }) => css`
max-width: ${theme.sizeUnit * 60}px;
height: ${theme.controlHeightSM}px;
padding-inline: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeLG}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
background-color: transparent;
transition: background-color ${theme.motionDurationMid};
&:hover,
&:focus {
background-color: ${theme.colorFillQuaternary};
}
&::placeholder {
font-weight: ${theme.fontWeightNormal};
color: ${theme.colorTextTertiary};
}
`}
`;
/**
* The dashboard's name, edited where it is read.
*
* It is stored on the root node rather than in this component, because a name
* is something the dashboard has and not something this screen remembers: put
* in page state it would be invisible to the assistant, unreachable by the
* client tools, and gone on the next navigation. The root canvas is the only
* node a dashboard-level fact can belong to, so that is where it lives.
*
* A title is also a `markdown` block an author can place at the top of the
* canvas, and that stays true — this is a different thing with a different
* job. That one is content, laid out and arranged like any other block; this
* one is what the dashboard is called.
*
* The draft commits on blur rather than on every keystroke: a name being
* typed is not a name, and one commit per character would be one revision
* tick per character for everything subscribed to the store.
*/
const Title = ({ nodeId, title }: { nodeId: string; title: string }) => {
const [draft, setDraft] = useState(title);
// What was accepted replaces the draft, because the draft was a view of it:
// a rename the assistant makes while this is on screen has to show.
useEffect(() => setDraft(title), [title]);
return (
<TitleInput
size="small"
variant="borderless"
value={draft}
aria-label={t('Dashboard title')}
placeholder={t('Untitled dashboard')}
data-test="header-title"
onChange={event => setDraft(event.target.value)}
onBlur={() => {
const next = draft.trim();
// An empty name is not a rename. Restoring the draft rather than
// writing the blank is what keeps a stray select-all-and-delete from
// silently leaving the dashboard nameless.
if (next === '') {
setDraft(title);
} else if (next !== title) {
provider.updateProps(nodeId, { title: next });
}
}}
/>
);
};
/**
* The bar itself.
*
* Inset horizontally the way the rest of the app insets a page header, so the
* left edge of the bar and the left edge of the work below it are one line
* rather than two a few pixels apart. The rule beneath is `colorSplit` — what
* this app draws a separator with — rather than the heavier border it shares
* with the boxes that hold things.
*/
const Bar = styled.header`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
flex: 0 0 auto;
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 4}px;
border-bottom: 1px solid ${theme.colorSplit};
background-color: ${theme.colorBgContainer};
`}
`;
/** What an author does to the whole dashboard, at the end they read last. */
const Actions = styled.span`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
margin-left: auto;
`}
`;
/**
* The dashboard's header: what this dashboard is, and what can be done to it.
*
* Two kinds of thing share the bar. On the left is the dashboard as the
* product would know it — where to start from, where it has been, what it is
* called, whether it is published, and whose it is. On the right is what an
* author does to the whole of it: step back through what they did, or write
* it down.
*
* How the canvas is arranged is not among them. It reads like chrome and is
* not: it is a property of the root node, sitting in the same `layout` the
* columns and the gap sit in, and asking for it here put one third of that
* one decision on the other side of the screen from the rest. It is asked in
* the root's own properties now, where a canvas is selected and arranged in
* one place.
*/
export default function DashboardHeader(): ReactElement {
useDashboardRevision();
const root = provider.getRoot();
return (
<Bar data-test="dashboard-header">
{/* Where this dashboard came from: a starting point to build on, asked
before the work rather than during it, which is why it leads the
bar. History used to sit beside it on that reasoning and has gone to
the other end — it is read against saving, not against starting. */}
<Inert label={t('Templates')} test="header-templates" reads>
{t('Templates')}
</Inert>
<Title
nodeId={root.id}
title={typeof root.props?.title === 'string' ? root.props.title : ''}
/>
<Inert label={t('Favorite')} test="header-favorite" buttonStyle="link">
<Icons.StarOutlined iconSize="m" />
</Inert>
{/* Nothing here can publish, so the chip states the only status this
page can honestly claim. */}
<span data-test="header-published">
<PublishedLabel isPublished={false} />
</span>
{/* Beside the status rather than opposite it: whether a dashboard is a
draft, whose it is, and when it was last written are one answer to
one question — what state is this in — and they are read together. */}
<Metadata />
<Actions>
{/* Icons, not words, because these two are reached by muscle memory
far more often than they are read. The name stays on them for
anyone not reading with their eyes. */}
<Inert label={t('Undo')} test="header-undo">
<Icons.UndoOutlined iconSize="s" />
</Inert>
<Inert label={t('Redo')} test="header-redo">
<Icons.RedoOutlined iconSize="s" />
</Inert>
{/* Stepping back through the work and writing it down are two
different acts on the bar's one crowded end, and at an even gap
the four of them read as one run of controls. The rule is what
says where one pair stops and the other starts. */}
<Divider type="vertical" />
{/* Saving commits a version; History is the versions already
committed. One concern, read in one place — so the record sits
immediately before the button that produces what it lists, rather
than at the far side of the bar from it. */}
<Inert label={t('History')} test="header-history" reads>
{t('History')}
</Inert>
<Inert label={t('Save')} test="header-save" reads>
{t('Save')}
</Inert>
</Actions>
</Bar>
);
}
@@ -0,0 +1,436 @@
/**
* 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, useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import rison from 'rison';
import { SupersetClient } from '@superset-ui/core';
import { t, tn } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { Collapse, Form } from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import type { TagType } from 'src/components';
import type Subject from 'src/types/Subject';
import type { SubjectPickerValue } from 'src/features/subjects/SubjectPicker';
import { useModalValidation } from 'src/components/Modal';
import {
AccessSection,
AdvancedSection,
BasicInfoSection,
CertificationSection,
RefreshSection,
StylingSection,
} from 'src/dashboard/components/PropertiesModal/sections';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
/**
* The dashboard's own fields, as the root node stores them.
*
* Named once because three things have to agree on them: what is read out of
* the root to fill the form, what is written back, and what counts as a
* change worth committing.
*/
const TEXT_FIELDS = [
'title',
'slug',
'description',
'certifiedBy',
'certificationDetails',
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
type TextValues = Record<TextField, string>;
interface FetchedTheme {
id: number;
theme_name: string;
json_data?: string;
}
const asString = (value: unknown): string =>
typeof value === 'string' ? value : '';
/**
* A section's name, at the weight the rest of this panel names things at.
*
* The sections below are drawn for a modal, and the wrapper they come with
* dresses each one as a heading with a subtitle, a banded background and a
* tick saying it validates. In a modal that is the whole screen and the
* reader has nothing else to look at; in a rail beside the canvas it shouts
* over the fields it introduces, and sat a heading twice the size of the
* `Arrangement` heading directly beneath it.
*
* So the sections are kept and their wrapper is not: this matches `Section`
* in the Inspector, which is what a group of fields is called everywhere else
* in this panel — the same size as the labels it introduces, carrying the
* difference in weight rather than in size, so a heading is not set in less
* than the fields beneath it. The ticks go with the wrapper — they report on a
* save that this page cannot do, and each section still says what is wrong
* with it where the wrong thing is.
*/
const sectionLabel = (
theme: ReturnType<typeof useTheme>,
title: string,
): ReactElement => (
<span
style={{
fontSize: theme.fontSize,
fontWeight: theme.fontWeightStrong,
color: theme.colorText,
}}
>
{title}
</span>
);
/**
* The panel, and the one thing `size="small"` cannot reach on its own.
*
* A global rule sets `padding: 4px 8px` on every `input[type="text"]` in the
* app. antd sizes a small input by zeroing its own block padding, so that
* rule wins on specificity and a field marked `ant-input-sm` still renders at
* the middle height — eight pixels taller than every other input in this
* rail. The sections below write `type="text"` explicitly, which is what puts
* them in the global rule's way.
*
* Scoped to this panel rather than fixed at the global rule, which is load
* bearing for the rest of the app and not this change's to move.
*/
const Panel = styled.div`
${({ theme }) => css`
padding-top: ${theme.sizeUnit * 3}px;
font-size: ${theme.fontSizeSM}px;
/* Doubled deliberately. The global rule is a class plus an attribute
selector, the same specificity this would otherwise have, and it is
injected later — so matching it is losing to it. */
&& input[type='text'] {
padding-block: 0;
padding-inline: ${theme.sizeUnit * 2}px;
}
`}
`;
/** How many blocks are on the dashboard, at any depth. */
const countBlocks = (id: string): number =>
(provider.getNode(id)?.children ?? []).reduce(
(total, childId) => total + 1 + countBlocks(childId),
0,
);
/**
* Everything the dashboard is, as opposed to everything on it.
*
* The six sections are the ones `PropertiesModal` already draws, reused whole
* rather than reimplemented: the modal and this panel are two ways into one
* set of fields, and a second implementation is how the two quietly stop
* agreeing about what a dashboard has. Their wrapper is not reused — see
* {@link sectionLabel} for why a modal's headings do not belong in a rail.
*
* Everything is stored on the root node's props, beside the `title` the
* header already keeps there — the only place a dashboard-level fact is
* visible to the assistant and reachable by the client tools. Nothing is
* persisted, because nothing on this page is; what that means for the reader
* is said plainly at the top of the panel rather than left to be discovered
* at the disabled Save button.
*
* Text commits on blur, through one handler on the container rather than one
* per field: every input and both editors bubble a blur, and one commit per
* field left beats one revision tick per keystroke. Discrete controls — the
* pickers, the colour scheme, the refresh interval, the switch — commit in
* their own handler, because there is no typing to wait out and a dropdown
* that closes on an uncommitted value reads as broken.
*/
export default function DashboardProperties(): ReactElement {
useDashboardRevision();
const theme = useTheme();
const root = provider.getRoot();
const props = useMemo(() => root.props ?? {}, [root.props]);
const [form] = Form.useForm();
/** What the root currently says, in the shape the form takes. */
const accepted = useMemo(
() =>
Object.fromEntries(
TEXT_FIELDS.map(key => [key, asString(props[key])]),
) as TextValues,
[props],
);
// The form holds a draft of the text fields, and the draft is a view of
// what was accepted — so a rename made in the header, or by the assistant,
// replaces it rather than being typed over.
useEffect(() => form.setFieldsValue(accepted), [accepted, form]);
// Both editors report every keystroke. Held here and committed with the
// rest of the text, so an unfinished CSS rule is not a revision.
const [customCss, setCustomCss] = useState(() => asString(props.customCss));
const [jsonMetadata, setJsonMetadata] = useState(() =>
asString(props.jsonMetadata),
);
useEffect(() => setCustomCss(asString(props.customCss)), [props.customCss]);
useEffect(
() => setJsonMetadata(asString(props.jsonMetadata)),
[props.jsonMetadata],
);
const jsonAnnotations = useJsonValidation(jsonMetadata, {
errorPrefix: 'Invalid JSON metadata',
});
const { validationStatus, validateSection } = useModalValidation({
sections: [
{
key: 'basic',
name: t('General information'),
validator: () =>
form.getFieldValue('title')?.trim()
? []
: [t('Dashboard name is required')],
},
{
key: 'advanced',
name: t('Advanced settings'),
validator: () =>
jsonAnnotations.length > 0 ? [t('Invalid JSON metadata')] : [],
},
],
});
const write = useCallback(
(next: Record<string, unknown>) => provider.updateProps(root.id, next),
[root.id],
);
/**
* Commits every text field that changed, on the way out of any of them.
*
* Two are refused rather than written. An emptied name is not a rename —
* the same rule the header title keeps, and the reason a stray
* select-all-and-delete cannot leave the dashboard nameless. Unparseable
* JSON metadata is not metadata; the section already shows where it broke,
* and writing a string no reader can parse would leave the dashboard in a
* state only this field could get it out of.
*/
const commit = useCallback((): void => {
validateSection('basic');
validateSection('advanced');
const draft = form.getFieldsValue() as Partial<TextValues>;
const changed: Record<string, unknown> = {};
TEXT_FIELDS.forEach(key => {
const value = asString(draft[key]);
if (key === 'title' && value.trim() === '') {
form.setFieldsValue({ title: accepted.title });
return;
}
if (value !== accepted[key]) {
changed[key] = value;
}
});
if (customCss !== asString(props.customCss)) {
changed.customCss = customCss;
}
if (
jsonMetadata !== asString(props.jsonMetadata) &&
jsonAnnotations.length === 0
) {
changed.jsonMetadata = jsonMetadata;
}
if (Object.keys(changed).length > 0) {
write(changed);
}
}, [
accepted,
customCss,
form,
jsonAnnotations.length,
jsonMetadata,
props.customCss,
props.jsonMetadata,
validateSection,
write,
]);
// Offered to StylingSection, which lists them. Fetched here because the
// panel is where they are needed and nothing else on this page knows the
// dashboard has a theme at all. A failure leaves the list empty rather than
// taking the panel down with it — every other field still edits.
const [themes, setThemes] = useState<FetchedTheme[]>([]);
useEffect(() => {
const query = rison.encode({
columns: ['id', 'theme_name', 'is_system', 'json_data'],
filters: [{ col: 'is_system', opr: 'eq', value: false }],
});
let live = true;
SupersetClient.get({ endpoint: `/api/v1/theme/?q=${query}` })
.then(({ json }) => {
if (live) setThemes(json.result ?? []);
})
.catch(() => {});
return () => {
live = false;
};
}, []);
const blocks = countBlocks(root.id);
return (
// One handler for every field that is typed into: each bubbles its blur
// here, and what changed is worked out once rather than remembered per
// field.
<Panel data-test="dashboard-properties" onBlur={commit}>
<h3
data-test="dashboard-properties-name"
style={{
margin: 0,
fontSize: theme.fontSize,
fontWeight: theme.fontWeightStrong,
color: theme.colorText,
}}
>
{accepted.title || t('Untitled dashboard')}
</h3>
<p
data-test="dashboard-properties-counts"
style={{
margin: `${theme.sizeUnit}px 0 0`,
color: theme.colorTextSecondary,
}}
>
{/* Filters are a literal nothing rather than a number that moves:
this builder has no concept of one yet, and a count that could
only ever read zero is still the honest answer to what is here. */}
{`${tn('%s block', '%s blocks', blocks, blocks)}, ${tn(
'%s filter',
'%s filters',
0,
0,
)}`}
</p>
<p
data-test="dashboard-properties-caption"
style={{
margin: `${theme.sizeUnit * 2}px 0 ${theme.sizeUnit * 3}px`,
color: theme.colorTextTertiary,
}}
>
{t(
'These belong to the dashboard rather than to its contents. Nothing here is saved yet — the builder holds them in memory.',
)}
</p>
{/* `size="small"` reaches every control the reused sections draw: they
are written for a modal, where a control has the room to be full
height, and this rail spends its width on the fields themselves. */}
<Form form={form} layout="vertical" size="small" initialValues={accepted}>
<Collapse
ghost
size="small"
expandIconPosition="start"
defaultActiveKey={['basic']}
items={[
{
key: 'basic',
label: sectionLabel(theme, t('General information')),
children: (
<BasicInfoSection
form={form}
validationStatus={validationStatus}
/>
),
},
{
key: 'access',
label: sectionLabel(theme, t('Access & ownership')),
children: (
<AccessSection
isLoading={false}
tags={(props.tags as TagType[]) ?? []}
editors={(props.editors as Subject[]) ?? []}
viewers={(props.viewers as Subject[]) ?? []}
onChangeEditors={(editors: SubjectPickerValue[]) =>
write({ editors })
}
onChangeViewers={(viewers: SubjectPickerValue[]) =>
write({ viewers })
}
onChangeTags={tags => write({ tags })}
onClearTags={() => write({ tags: [] })}
/>
),
},
{
key: 'styling',
label: sectionLabel(theme, t('Styling')),
children: (
<StylingSection
themes={themes}
selectedThemeId={(props.themeId as number) ?? null}
colorScheme={asString(props.colorScheme)}
customCss={customCss}
hasCustomLabelsColor={false}
showChartTimestamps={props.showChartTimestamps === true}
onThemeChange={value => write({ themeId: value || null })}
onColorSchemeChange={colorScheme => write({ colorScheme })}
onCustomCssChange={setCustomCss}
onShowChartTimestampsChange={showChartTimestamps =>
write({ showChartTimestamps })
}
/>
),
},
{
key: 'refresh',
label: sectionLabel(theme, t('Refresh settings')),
children: (
<RefreshSection
refreshFrequency={(props.refreshFrequency as number) ?? 0}
onRefreshFrequencyChange={refreshFrequency =>
write({ refreshFrequency })
}
/>
),
},
{
key: 'certification',
label: sectionLabel(theme, t('Certification')),
children: <CertificationSection isLoading={false} />,
},
{
key: 'advanced',
label: sectionLabel(theme, t('Advanced settings')),
children: (
<AdvancedSection
jsonMetadata={jsonMetadata}
jsonAnnotations={jsonAnnotations}
validationStatus={validationStatus}
onJsonMetadataChange={setJsonMetadata}
/>
),
},
]}
/>
</Form>
</Panel>
);
}
@@ -0,0 +1,263 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import 'src/core/dashboard';
import EditorPanel from './EditorPanel';
const provider = DashboardProvider.getInstance();
beforeEach(() => {
provider.reset();
});
const mount = () => {
const onAdd = jest.fn();
render(<EditorPanel onAdd={onAdd} />);
return onAdd;
};
test('the panel offers building blocks, properties and an outline', () => {
mount();
expect(
screen.getByRole('tab', { name: 'Building blocks' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Properties' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Outline' })).toBeInTheDocument();
});
test('building blocks is what you start on, and it lists what is registered', () => {
mount();
// The list is `views.getViews('dashboard.buildingBlocks')` — the same
// registry BuildingBlockView resolves a renderer through. Nothing here
// names a block, so registering one makes it placeable with no edit.
expect(screen.getByTestId('palette')).toBeVisible();
expect(screen.getByTestId('palette-markdown')).toBeVisible();
expect(screen.getByTestId('palette-echarts')).toBeVisible();
});
test('the root grid is not offered in the palette, since nesting one is not an authored feature', () => {
mount();
// The root's own type is resolved directly by `BuildingBlockView`, never
// through the `dashboard.buildingBlocks` registry this palette lists from
// (see `registerBuiltInBuildingBlocks`) — so there is nothing registered
// under either name to ever show up here in the first place.
expect(screen.queryByTestId('palette-canvas')).not.toBeInTheDocument();
expect(screen.queryByTestId('palette-grid')).not.toBeInTheDocument();
// 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 () => {
const onAdd = mount();
await userEvent.click(screen.getByTestId('palette-markdown'));
expect(onAdd).toHaveBeenCalledWith('markdown');
});
test('searching narrows the palette to what was asked for', async () => {
mount();
await userEvent.type(screen.getByTestId('palette-search'), 'markdown');
expect(screen.getByTestId('palette-markdown')).toBeVisible();
expect(screen.queryByTestId('palette-echarts')).not.toBeInTheDocument();
});
test('with nothing selected, properties says so rather than showing a stale block', async () => {
mount();
await userEvent.click(screen.getByRole('tab', { name: 'Properties' }));
expect(screen.getByTestId('inspector-empty')).toBeVisible();
});
test('selecting something brings its properties forward', () => {
mount();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
});
act(() => provider.setSelection(id));
// A selection is the moment you want to configure the thing selected, so
// the panel follows rather than making the author find the tab.
expect(screen.getByTestId('inspector-identity')).toHaveTextContent(id);
});
test('the outline lists the dashboard and selects what you click', async () => {
mount();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
props: { content: 'Quarterly review' },
});
await userEvent.click(screen.getByRole('tab', { name: 'Outline' }));
// Markdown is labelled by its content: five rows all reading "Markdown"
// identify nothing.
expect(screen.getByTestId(`outline-row-${id}`)).toHaveTextContent(
'Quarterly review',
);
await userEvent.click(screen.getByTestId(`outline-row-${id}`));
expect(provider.getSelection()).toBe(id);
});
test('choosing a row in the outline leaves you in the outline', async () => {
mount();
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
});
await userEvent.click(screen.getByRole('tab', { name: 'Outline' }));
await userEvent.click(screen.getByTestId(`outline-row-${id}`));
// Reading a structure means going through it. A tab that ejected to
// Properties on the first row would hide the very row it just marked as
// selected.
expect(screen.getByTestId(`outline-row-${id}`)).toBeVisible();
expect(screen.getByRole('tab', { name: 'Outline' })).toHaveAttribute(
'aria-selected',
'true',
);
});
test('the list of tabs says what it is a list of', () => {
mount();
expect(
screen.getByRole('tablist', { name: 'Editor panel views' }),
).toBeInTheDocument();
});
/**
* The panel's width belongs to whoever is authoring. A property form is the
* widest thing here, and only the author knows how much canvas they are
* willing to spend on it.
*/
const widthOf = () =>
Number.parseInt(screen.getByTestId('editor-panel').style.width, 10);
test('the panel opens wide enough to edit a block in', () => {
mount();
expect(widthOf()).toBe(350);
});
test('the handle resizes from the keyboard, so a drag is not the only way', () => {
mount();
const handle = screen.getByTestId('panel-resize');
handle.focus();
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(widthOf()).toBe(366);
fireEvent.keyDown(handle, { key: 'End' });
expect(widthOf()).toBe(800);
fireEvent.keyDown(handle, { key: 'Home' });
expect(widthOf()).toBe(280);
});
test('the handle reports the width it actually has', () => {
mount();
// What a screen reader announces has to be the width on screen, or the
// control is lying about the only thing it does.
expect(screen.getByTestId('panel-resize')).toHaveAttribute(
'aria-valuenow',
'350',
);
});
test('the search field is set in from the panel edge and down from the tabs', () => {
mount();
// Flush against both, it reads as chrome around the list rather than the
// way into it.
expect(screen.getByTestId('palette')).toHaveStyle('padding-top: 12px');
});
test('a palette row can actually be dragged, as its grip promises', () => {
mount();
const row = screen.getByTestId('palette-markdown');
const setData = jest.fn();
// The grip beside the label promised a drag the row did not carry.
expect(row).toHaveAttribute('draggable', 'true');
row.dispatchEvent(
Object.assign(new Event('dragstart', { bubbles: true }), {
dataTransfer: { setData, effectAllowed: '' },
}),
);
expect(setData).toHaveBeenCalledWith(
'application/x-dashboard-building-block',
'markdown',
);
});
test('the panel can be got out of the way, and brought back', async () => {
mount();
// The canvas is the work; this rail is how you act on it, and an author
// reading a dashboard at full width wants it gone without losing where
// they were in it.
await userEvent.click(screen.getByTestId('panel-collapse'));
expect(screen.queryByRole('tab', { name: 'Building blocks' })).toBeNull();
expect(screen.getByTestId('panel-expand')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('panel-expand'));
expect(
screen.getByRole('tab', { name: 'Building blocks' }),
).toBeInTheDocument();
});
test('a closed panel keeps the width it was opened at', async () => {
mount();
const grip = screen.getByTestId('panel-resize');
grip.focus();
fireEvent.keyDown(grip, { key: 'End' });
await userEvent.click(screen.getByTestId('panel-collapse'));
await userEvent.click(screen.getByTestId('panel-expand'));
// Closing is not resizing. A panel that reopened at the default would
// silently discard a width the author had already chosen.
expect(screen.getByTestId('editor-panel')).toHaveStyle('width: 800px');
});
test('a closed panel offers no edge to drag', async () => {
mount();
await userEvent.click(screen.getByTestId('panel-collapse'));
// There is nothing to size: the strip is exactly as wide as the one
// control on it, and dragging it wider would be a third state.
expect(screen.queryByTestId('panel-resize')).toBeNull();
});
@@ -0,0 +1,342 @@
/**
* 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, 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 { Button, Tabs } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
import Inspector from './Inspector';
import Outline from './Outline';
import Palette from './Palette';
type PanelTab = 'blocks' | 'properties' | 'outline';
/**
* How wide the panel opens, and how far it may be dragged.
*
* The default is set by the Properties tab, which holds a block's whole set
* of fields and is the widest thing here; the palette and the outline are
* narrow whatever they are given. The ceiling leaves a usable canvas on a
* small screen.
*/
const DEFAULT_WIDTH = 350;
const MIN_WIDTH = 280;
const MAX_WIDTH = 800;
/** How far one arrow press moves the edge. */
const KEYBOARD_STEP = 16;
const clampWidth = (width: number): number =>
Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
/**
* The rail, open or shut.
*
* Separated from the canvas with `colorSplit`, the same hairline the header
* rules itself off with, so the three edges of the authoring shell are one
* line and not three weights of one.
*/
const Rail = styled.aside`
${({ theme }) => css`
flex-shrink: 0;
display: flex;
flex-direction: column;
position: relative;
/* The panel is a fixed-height column and the scrolling happens inside the
tab body, so the tab bar stays put however long a form gets. */
overflow: hidden;
padding: ${theme.sizeUnit * 2}px;
border-right: 1px solid ${theme.colorSplit};
background-color: ${theme.colorBgContainer};
`}
`;
const ClosedRail = styled.aside`
${({ theme }) => css`
flex-shrink: 0;
display: flex;
justify-content: center;
padding: ${theme.sizeUnit}px;
border-right: 1px solid ${theme.colorSplit};
background-color: ${theme.colorBgContainer};
`}
`;
/**
* The edge, as something to take hold of.
*
* The hit area is wide enough to aim at and the line inside it is not: a band
* of colour the width of the target announced itself as a bar being added to
* the layout rather than as the edge answering. What lights is a rule down the
* middle, which is the edge the pointer is already on.
*
* Coloured on focus as well as on hover, because focus is the state with no
* cursor to read — and the width of the authoring surface must be reachable
* without a pointer at all.
*/
const Grip = styled.div<{ $active: boolean }>`
${({ theme, $active }) => css`
position: absolute;
top: 0;
right: 0;
bottom: 0;
/* Wide enough to be worth aiming at, sitting over the panel's own border
so the edge is the target rather than a strip beside it. */
width: ${theme.sizeUnit * 2}px;
z-index: 1;
cursor: col-resize;
touch-action: none;
/* Sat at the edge itself rather than a few pixels inside it: what lights
has to be the line the panel already ends on, or it reads as a second
rule appearing beside the first. */
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 2px;
background-color: ${$active ? theme.colorPrimary : 'transparent'};
transition: background-color ${theme.motionDurationMid};
}
&:focus-visible {
outline: none;
}
`}
`;
/**
* The authoring panel: one rail, three ways of working on a dashboard.
*
* Placing a block, editing one and finding one are the same activity at
* different moments, and an author is only ever doing one of them. Giving
* each its own permanent rail would spend the canvas on a choice made moment
* to moment, so they share a rail and the canvas keeps the room.
*/
export default function EditorPanel({
onAdd,
}: {
onAdd: (type: string) => void;
}): ReactElement {
useDashboardRevision();
const [tab, setTab] = useState<PanelTab>('blocks');
const [width, setWidth] = useState(DEFAULT_WIDTH);
/** Whether the rail is out of the way. The width it had is kept either way. */
const [closed, setClosed] = useState(false);
/** Whether the grip is showing itself — under the pointer, or focused. */
const [gripped, setGripped] = useState(false);
const panel = useRef<HTMLElement | null>(null);
/** Where a drag started, so a slow drag and a fast one land the same place. */
const from = useRef<{ x: number; width: number } | null>(null);
/**
* Selecting something shows it, and that is a response to the selection
* changing rather than to it existing: an author who goes back to the
* palette with a block still selected stays there, because nothing changed.
*
* A selection made in the Outline is the exception. Reading a structure
* means going through it, and a tab that ejected on the first row would
* hide the very row it had just marked as selected — so the Outline sets
* the selection without moving anyone, and every other route brings
* Properties forward.
*/
const selection = provider.getSelection();
const [shown, setShown] = useState(selection);
if (selection !== shown) {
setShown(selection);
if (selection !== undefined && tab === 'blocks') {
setTab('properties');
}
}
/**
* A name for the list of tabs. antd forwards unknown props to its own root
* element rather than to the `role="tablist"` it renders inside, so the
* only place this name can be put is on that element.
*/
useEffect(() => {
panel.current
?.querySelector('[role="tablist"]')
?.setAttribute('aria-label', t('Editor panel views'));
}, []);
/**
* Resizing, by pointer and by key.
*
* The pointer is captured on the handle, so a drag faster than the browser
* can paint does not slip off a small target and strand the panel mid-width.
* Each move is measured from where the drag began rather than from the last
* position, so a drag that leaves the window and comes back resumes instead
* of drifting.
*
* The keys are not a convenience: a grip only a mouse can move makes the
* width of the authoring surface unreachable to anyone driving this from
* the keyboard, and the width is the whole of what the control does.
*/
const startDrag = useCallback(
(event: PointerEvent<HTMLDivElement>): void => {
from.current = { x: event.clientX, width };
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[width],
);
const drag = (event: PointerEvent<HTMLDivElement>): void => {
if (from.current !== null) {
setWidth(clampWidth(from.current.width + event.clientX - from.current.x));
}
};
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
from.current = null;
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const nudge = (event: KeyboardEvent<HTMLDivElement>): void => {
const moves: Record<string, (current: number) => number> = {
ArrowRight: current => current + KEYBOARD_STEP,
ArrowLeft: current => current - KEYBOARD_STEP,
Home: () => MIN_WIDTH,
End: () => MAX_WIDTH,
};
const move = moves[event.key];
if (move !== undefined) {
// Arrow and Home/End would otherwise scroll the panel out from under
// the author while they are sizing it.
event.preventDefault();
setWidth(current => clampWidth(move(current)));
}
};
/**
* Out of the way, and back.
*
* The canvas is the work and this rail is how an author acts on it — but
* reading a dashboard, or showing one to someone, wants the whole width.
* Closing keeps `width` untouched rather than zeroing it, so reopening
* restores the width the author chose instead of silently discarding it.
*
* Closed, the panel is a strip carrying one control rather than nothing at
* all: a rail that vanished with no way back is a rail an author loses.
* The strip has no edge to drag, because it has no size to choose.
*/
if (closed) {
return (
<ClosedRail data-test="editor-panel" aria-label={t('Editor panel')}>
<Button
buttonSize="xsmall"
buttonStyle="link"
data-test="panel-expand"
aria-label={t('Show the editor panel')}
aria-expanded={false}
tooltip={t('Show the editor panel')}
placement="right"
onClick={() => setClosed(false)}
>
<Icons.MenuUnfoldOutlined iconSize="m" />
</Button>
</ClosedRail>
);
}
return (
<Rail
ref={panel}
data-test="editor-panel"
aria-label={t('Editor panel')}
// The one thing that cannot be a class: it is a value the author sets by
// dragging, and a class per pixel is a stylesheet per drag.
style={{ width }}
>
<Tabs
activeKey={tab}
onChange={key => setTab(key as PanelTab)}
size="small"
style={{ flex: 1, minHeight: 0 }}
// Without this, the tab body's own overflow stays `visible` (the
// component's default) and a tall form or block list bleeds past the
// rail's bottom edge instead of scrolling — the rail's `overflow:
// hidden` then clips it silently rather than offering a scrollbar.
allowOverflow={false}
// Riding the tab bar rather than sitting above it: closing the panel
// is done to the panel, and a row of its own for one icon would cost
// the height of a row on every screen that never uses it.
tabBarExtraContent={{
right: (
<Button
buttonSize="xsmall"
buttonStyle="link"
data-test="panel-collapse"
aria-label={t('Hide the editor panel')}
aria-expanded
tooltip={t('Hide the editor panel')}
placement="bottom"
onClick={() => setClosed(true)}
>
<Icons.MenuFoldOutlined iconSize="m" />
</Button>
),
}}
items={[
{
key: 'blocks',
label: t('Building blocks'),
children: <Palette onAdd={onAdd} />,
},
{
key: 'properties',
label: t('Properties'),
children: <Inspector />,
},
{
key: 'outline',
label: t('Outline'),
children: <Outline />,
},
]}
/>
{/* The rule's suggested `hr` is the static kind of separator: it takes
neither focus nor a value, and both are what make this one a
splitter an author can reach without a pointer. */}
<Grip
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="separator"
tabIndex={0}
data-test="panel-resize"
aria-orientation="vertical"
aria-label={t('Resize the editor panel')}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
$active={gripped}
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={endDrag}
onKeyDown={nudge}
onPointerEnter={() => setGripped(true)}
onPointerLeave={() => setGripped(from.current !== null)}
onFocus={() => setGripped(true)}
onBlur={() => setGripped(false)}
/>
</Rail>
);
}
@@ -0,0 +1,89 @@
/**
* 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, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { Button, type ButtonProps } from '@superset-ui/core/components';
const NOT_AVAILABLE = t('Not available yet');
/**
* This prototype's controls, at two of the shared Button's own sizes.
*
* Nothing here is drawn at full size: the bars and rails are chrome around the
* work rather than the work itself, and every pixel they take is one the
* canvas does not get. But the two kinds of control on them are not read the
* same way. A word is read, and one squeezed to the smallest step there is is
* read slowly; an icon is recognised by its shape, and loses nothing there.
*
* Said in `buttonSize`, which is the prop the shared `Button` actually reads.
* `size` is antd's, and the wrapper writes its own height over whatever antd
* does with it — so every control here asked for `size="small"`, got the full
* 32px default, and was then pushed back down by a hand-written height,
* padding and font size at each site. Those helpers were a copy of this scale
* maintained beside it, free to drift from it and answering to no theme
* override; `buttonSize` is the scale itself.
*/
/**
* An affordance that is present, named and honest about not working.
*
* Most of this prototype's chrome is one. The builder keeps its tree in
* memory and has no dashboard row behind it: nothing can be saved,
* favourited, published or refreshed, and there is no history to step
* through. Drawing them disabled says which parts of the product this page is
* still missing; drawing them live and inert would teach something false
* about all of them.
*
* `Button` renders a disabled control inside a span so its tooltip survives —
* a bare disabled button swallows the pointer events a tooltip listens for,
* and the explanation would never reach the one control that needs it. That
* is the whole reason this is a component rather than a prop spread at each
* site, and it is why a second home for it did not get a second copy.
*/
export default function Inert({
label,
test,
buttonStyle,
/** Whether this one is read as a word rather than recognised as a shape. */
reads,
style,
children,
}: {
label: string;
test: string;
buttonStyle?: ButtonProps['buttonStyle'];
reads?: boolean;
style?: ButtonProps['style'];
children: ReactNode;
}): ReactElement {
return (
<Button
buttonSize={reads ? 'small' : 'xsmall'}
buttonStyle={buttonStyle}
disabled
aria-label={label}
data-test={test}
tooltip={`${label}${NOT_AVAILABLE}`}
placement="bottom"
style={style}
>
{children}
</Button>
);
}
@@ -0,0 +1,374 @@
/**
* 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 userEvent from '@testing-library/user-event';
import {
fireEvent,
render,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import 'src/core/dashboard';
import Inspector from './Inspector';
const provider = DashboardProvider.getInstance();
beforeEach(() => {
provider.reset();
});
/**
* Brings the JSON half forward. The panel opens on the form, so every test
* that reads the raw record has to say so — which is also the assertion that
* the form is what comes first.
*/
const openJson = async () => {
await userEvent.click(screen.getByRole('tab', { name: 'JSON' }));
return screen.findByTestId('inspector-props');
};
const select = (type: string, props?: Record<string, unknown>) => {
const id = provider.addBuildingBlock(provider.getRoot().id, 0, {
type,
...(props ? { props } : {}),
});
provider.setSelection(id);
render(<Inspector />);
return id;
};
test('a markdown block placed a moment ago can still be given content', async () => {
// The block arrives with no props at all. Waiting for a `content` key to
// exist before offering the field is what left a fresh block with no way
// to be given one.
const id = select('markdown');
await userEvent.type(
screen.getByTestId('inspector-content'),
'Quarterly review',
);
await userEvent.tab();
expect(provider.getNode(id)?.props?.content).toBe('Quarterly review');
});
test('content a block already has is what the field shows', () => {
select('markdown', { content: 'Welcome' });
expect(screen.getByTestId('inspector-content')).toHaveValue('Welcome');
});
test('a block with no prose field is still authorable through its properties', async () => {
select('echarts');
// A chart's dataBinding and echartsOptions have never had a hand-editing
// path. They are just keys, and the general editor reaches every one.
expect(screen.queryByTestId('inspector-content')).not.toBeInTheDocument();
expect(await openJson()).toBeInTheDocument();
});
test('applying properties writes them to the block', async () => {
const id = select('echarts');
await openJson();
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '{"dataBinding":{"datasetId":3,"metrics":["count"]}}' },
});
await userEvent.click(screen.getByTestId('inspector-props-apply'));
expect(provider.getNode(id)?.props?.dataBinding).toEqual({
datasetId: 3,
metrics: ['count'],
});
});
test('a key deleted from the properties stops reaching the block', async () => {
const id = select('echarts', { keep: 1, drop: 2 });
await openJson();
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '{"keep":1}' },
});
await userEvent.click(screen.getByTestId('inspector-props-apply'));
// `updateProps` merges, so omitting a key would silently do nothing and
// the block would go on rendering from the value it appeared to lose.
// Sending `undefined` is as close to a removal as a merge can express: the
// block reads nothing there, and the key does not survive serialization
// back into the editor.
expect(provider.getNode(id)?.props?.drop).toBeUndefined();
expect(provider.getNode(id)?.props?.keep).toBe(1);
expect(screen.getByTestId('inspector-props')).toHaveValue(
JSON.stringify({ keep: 1 }, null, 2),
);
});
test('malformed properties cannot be applied, and stay on screen to be fixed', async () => {
const id = select('echarts', { kept: true });
await openJson();
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '{ "broken": ' },
});
expect(screen.getByTestId('inspector-props-apply')).toBeDisabled();
expect(screen.getByTestId('inspector-props-error')).toBeInTheDocument();
// The draft is the author's; it is not reverted out from under them.
expect(screen.getByTestId('inspector-props')).toHaveValue('{ "broken": ');
expect(provider.getNode(id)?.props?.kept).toBe(true);
});
test('properties that are not an object are refused', async () => {
select('echarts');
await openJson();
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '[1, 2, 3]' },
});
expect(screen.getByTestId('inspector-props-apply')).toBeDisabled();
});
/** The form is what the panel opens on, so this only has to find it. */
const openForm = async () => screen.findByTestId('inspector-props-form');
test('properties can be edited as a form or as JSON, whichever suits', async () => {
select('echarts', { title: 'Revenue' });
// Two views of one set of values, not two places a value can live. The
// form is where the values are filled in and is what the panel opens on;
// JSON is where the shape is changed, since it alone can add or drop a key.
expect(screen.getByRole('tab', { name: 'Form' })).toHaveAttribute(
'aria-selected',
'true',
);
await openForm();
expect(await openJson()).toBeInTheDocument();
});
test('the form is built from the properties the block is actually holding', async () => {
select('echarts', { title: 'Revenue', limit: 10 });
const form = await openForm();
// No block type is named anywhere in this panel, so a contributed block
// gets a form on the same terms a built-in one does.
expect(form).toHaveTextContent('Title');
expect(form).toHaveTextContent('Limit');
expect(screen.getByDisplayValue('Revenue')).toBeInTheDocument();
});
test('a value typed into the form reaches the block', async () => {
const id = select('echarts', { title: 'Revenue' });
await openForm();
await userEvent.clear(screen.getByDisplayValue('Revenue'));
await userEvent.type(screen.getByRole('textbox'), 'Quarterly revenue');
// Awaited because JsonForms debounces what it reports by 10ms — which is
// also why this writes on change rather than on blur: a commit on blur
// fires before that debounce lands and would save the value as it stood a
// keystroke earlier.
await waitFor(() =>
expect(provider.getNode(id)?.props?.title).toBe('Quarterly revenue'),
);
});
test('each half of the properties editor is set down from the tabs above it', async () => {
select('echarts', { title: 'Revenue' });
// Flush against the tab bar, whichever label comes first reads as a caption
// on the tabs rather than as the head of the field under it — the same set
// down the panel and the palette already take from theirs.
expect((await openForm()).parentElement).toHaveStyle('padding-top: 12px');
await openJson();
expect(screen.getByTestId('inspector-props-json')).toHaveStyle(
'padding-top: 12px',
);
});
test('the properties on screen can be taken away as JSON', async () => {
const writeText = jest.fn();
const original = global.navigator.clipboard;
// @ts-expect-error jsdom ships no clipboard to spy on
global.navigator.clipboard = { write: writeText, writeText };
select('echarts', { title: 'Revenue' });
await openJson();
// What is copied is what is on screen, not what the block holds — an edit
// typed but not applied yet is the state most worth being able to take
// somewhere else.
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '{"title":"Quarterly"}' },
});
await userEvent.click(screen.getByTestId('inspector-props-copy'));
expect(writeText).toHaveBeenCalledWith('{"title":"Quarterly"}');
// @ts-expect-error restoring what jsdom did not have
global.navigator.clipboard = original;
});
test("the dashboard-wide properties are the dashboard's alone", async () => {
select('echarts', { title: 'Revenue' });
// What a dashboard is called, who may see it and how often it refreshes are
// properties of the dashboard, not of anything placed on it — a block asked
// for a URL slug would be asking for something it has no such thing as.
expect(screen.queryByTestId('dashboard-properties')).not.toBeInTheDocument();
[
'General information',
'Access & ownership',
'Styling',
'Refresh settings',
'Certification',
'Advanced settings',
].forEach(section =>
expect(screen.queryByText(section)).not.toBeInTheDocument(),
);
// And what a block does have stays where it is.
expect(await openForm()).toBeInTheDocument();
});
test('a block with no properties yet says where they are added', async () => {
select('echarts');
// A form generated from values cannot offer a field for a key nothing has
// written. Rendering nothing at all would read as a broken tab.
const form = await openForm();
expect(form).toHaveTextContent('JSON');
});
test('reverting restores what the block still has', async () => {
select('echarts', { kept: true });
await openJson();
fireEvent.change(screen.getByTestId('inspector-props'), {
target: { value: '{}' },
});
await userEvent.click(screen.getByTestId('inspector-props-revert'));
expect(screen.getByTestId('inspector-props')).toHaveValue(
JSON.stringify({ kept: true }, null, 2),
);
});
test('the panel is set down from the tabs above it', () => {
select('markdown');
// Flush against the tab bar, the first line reads as a caption belonging
// to the tabs rather than to the block it names.
expect(screen.getByTestId('inspector')).toHaveStyle('padding-top: 12px');
});
test('the empty state is set down too', () => {
render(<Inspector />);
expect(screen.getByTestId('inspector-empty')).toHaveStyle(
'padding-top: 12px',
);
});
const selectRoot = () => {
const rootId = provider.getRoot().id;
provider.setSelection(rootId);
render(<Inspector />);
return rootId;
};
test('the root does not offer a layout mode switch from the panel', () => {
selectRoot();
expect(screen.queryByTestId('layout-mode-switcher')).not.toBeInTheDocument();
expect(
screen.queryByTestId('inspector-section-arrangement'),
).not.toBeInTheDocument();
});
test('selecting the dashboard offers the properties the dashboard has', () => {
selectRoot();
// The six the saved dashboard's own properties modal asks for, reused
// whole — this panel and that modal are two ways into one set of fields.
expect(screen.getByTestId('dashboard-properties')).toBeInTheDocument();
[
'General information',
'Access & ownership',
'Styling',
'Refresh settings',
'Certification',
'Advanced settings',
].forEach(section => expect(screen.getByText(section)).toBeInTheDocument());
});
test('the dashboard is not a block, so it is not placed and cannot be deleted', () => {
selectRoot();
// `removeBuildingBlock` refuses the root outright, so a Delete there is a
// control that only ever raises; and the root is placed by nothing, so it
// has no column or row of its own to start at.
expect(screen.queryByTestId('inspector-delete')).not.toBeInTheDocument();
expect(
screen.queryByTestId('inspector-section-placement'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('inspector-identity')).not.toBeInTheDocument();
});
test('the panel counts what is on the dashboard', () => {
const rootId = provider.getRoot().id;
const section = provider.addBuildingBlock(rootId, 0, { type: 'tabs' });
provider.addBuildingBlock(section, 0, { type: 'markdown' });
provider.addBuildingBlock(rootId, 1, { type: 'markdown' });
selectRoot();
// Every block, at any depth — a section and what is inside it are both
// things on the dashboard.
expect(screen.getByTestId('dashboard-properties-counts')).toHaveTextContent(
'3 blocks, 0 filters',
);
});
test('a child is asked where it starts', () => {
const rootId = provider.getRoot().id;
const childId = provider.addBuildingBlock(rootId, 0, { type: 'markdown' });
provider.setSelection(childId);
render(<Inspector />);
expect(screen.getByTestId('inspector-col')).toBeInTheDocument();
expect(screen.getByTestId('inspector-row')).toBeInTheDocument();
expect(screen.getByTestId('inspector-colSpan')).toBeInTheDocument();
expect(screen.getByTestId('inspector-rowSpan')).toBeInTheDocument();
});
test('a container is not offered any arrangement fields from the panel', () => {
selectRoot();
[
'direction',
'wrap',
'justify',
'align',
'columns',
'gap',
'rowUnit',
].forEach(key =>
expect(screen.queryByTestId(`inspector-${key}`)).not.toBeInTheDocument(),
);
});
@@ -0,0 +1,577 @@
/**
* 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 { useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import type { dashboard as dashboardApi } from '@apache-superset/core';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import {
Button,
EmptyState,
Form,
Input,
InputNumber,
Tabs,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import copyTextToClipboard from 'src/utils/copy';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
import { blockLabel } from 'src/core/dashboard/blockLabel';
import DashboardProperties from './DashboardProperties';
import PropsForm from './PropsForm';
type LayoutProps = dashboardApi.LayoutProps;
const CHILD_FIELDS: readonly {
readonly key: keyof LayoutProps;
readonly label: string;
}[] = [
{ key: 'colSpan', label: t('Width (columns)') },
{ key: 'rowSpan', label: t('Height (rows)') },
{ key: 'col', label: t('Start column') },
{ key: 'row', label: t('Start row') },
];
/**
* A group of fields, and where one stops.
*
* The panel is a single column that can run several screens deep, and the
* headings alone were doing all the work of dividing it — set at the same
* weight as the field labels beneath them, they read as one more label rather
* than as the top of a group. The rule above each section is what actually
* separates them; the heading is bolder so a scan finds it first.
*/
const Group = styled.section`
${({ theme }) => css`
margin-top: ${theme.sizeUnit * 4}px;
padding-top: ${theme.sizeUnit * 4}px;
border-top: 1px solid ${theme.colorSplit};
`}
`;
/**
* At the size the fields under it are labelled, and heavier.
*
* Smaller and greyer than the labels it introduces, a section heading reads as
* a caption belonging to the field above rather than as the top of the group
* below — the hierarchy inverted, with "Content" the section set in less than
* "Content" the field. Weight carries the difference instead, with the rule
* above doing the separating.
*/
const GroupTitle = styled.h4`
${({ theme }) => css`
margin: 0 0 ${theme.sizeUnit * 2}px;
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
`}
`;
/** Where the panel ends, and the one control that ends the block with it. */
const Footer = styled.div`
${({ theme }) => css`
margin-top: ${theme.sizeUnit * 4}px;
padding-top: ${theme.sizeUnit * 4}px;
border-top: 1px solid ${theme.colorSplit};
`}
`;
/** What is selected, named the way the canvas and the Outline name it. */
const IdentityName = styled.h3`
${({ theme }) => css`
margin: 0;
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
overflow-wrap: anywhere;
`}
`;
const IdentityMeta = styled.p`
${({ theme }) => css`
margin: ${theme.sizeUnit}px 0 0;
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextTertiary};
word-break: break-all;
`}
`;
const Section = ({
title,
test,
children,
}: {
title: string;
test: string;
children: ReactNode;
}): ReactElement => (
<Group data-test={test}>
<GroupTitle>{title}</GroupTitle>
{children}
</Group>
);
/**
* A number that may be absent, and stays absent when cleared.
*
* Every one of these fields has a meaning for "not set" that differs from any
* number: a child with no `col` is auto-placed, and a container with no
* `columns` takes the default. Writing a zero when a field is emptied would
* turn "let the grid decide" into "pin it at nothing".
*/
const NumberField = ({
label,
value,
test,
onChange,
}: {
label: string;
value: number | undefined;
test: string;
onChange: (next: number | undefined) => void;
}): ReactElement => {
const theme = useTheme();
return (
<Form.Item label={label} style={{ marginBottom: theme.sizeUnit * 2 }}>
<InputNumber
size="small"
style={{ width: '100%' }}
value={value ?? null}
placeholder={t('Auto')}
data-test={test}
onChange={next => onChange(typeof next === 'number' ? next : undefined)}
/>
</Form.Item>
);
};
/**
* Block types whose renderer reads a plain-text `content` prop.
*
* A convenience over the general props editor below, not a special case in
* the render path: prose is miserable to write inside a JSON string, with
* every newline escaped and every quote doubled. Anything not named here is
* still fully authorable — through the editor that knows no types at all.
*/
const PLAIN_TEXT_CONTENT = new Set(['markdown']);
/** The `content` a block renders, edited where it is displayed. */
const ContentField = ({
nodeId,
content,
}: {
nodeId: string;
content: string;
}): ReactElement => {
const theme = useTheme();
const [draft, setDraft] = useState(content);
// What was accepted replaces the draft, because the draft was a view of it:
// an edit made by the assistant while this panel is open has to show.
useEffect(() => setDraft(content), [content, nodeId]);
return (
// "Text", not "Content": the section this sits in is already called
// Content, and the two stacked read as the same word said twice. What the
// box holds is prose, which is what the label should say.
<Form.Item label={t('Text')} style={{ marginBottom: theme.sizeUnit * 2 }}>
<Input.TextArea
size="small"
rows={4}
value={draft}
data-test="inspector-content"
onChange={event => setDraft(event.target.value)}
onBlur={() => {
if (draft !== content) {
provider.updateProps(nodeId, { content: draft });
}
}}
/>
</Form.Item>
);
};
const format = (props: Record<string, unknown> | undefined): string =>
JSON.stringify(props ?? {}, null, 2);
/** Long enough to be read, short enough not to outlast the glance at it. */
const COPIED_FOR_MS = 1500;
/**
* Everything a block renders from, offered whole and as text.
*
* This is the general answer to "how do I give this block its content", and
* it is general on purpose: a chart's `dataBinding` and `echartsOptions`, a
* table's `columnDefs`, and whatever an extension's block reads next year
* are all just keys here. A form per block type would need this panel to
* learn every type — the exact knowledge `BuildingBlockView` is built not to
* have, and what `PropsForm` generates a form without needing.
*
* This half is where the *shape* is decided, which is why it survives having
* a form beside it: a key that does not exist yet has no field, and can only
* be added by writing it.
*
* The draft is held until it parses and the author asks for it, so malformed
* JSON never reaches a block. What is applied is the whole record: keys the
* author deleted are sent as `undefined`, which is as close to a removal as
* a merge can express — the block reads `undefined` either way, and the key
* does not survive the next serialization back into this editor. Without
* that, deleting a line here would silently do nothing and the block would
* go on rendering from the value it appeared to lose.
*/
const PropsJsonEditor = ({
nodeId,
props,
}: {
nodeId: string;
props: Record<string, unknown> | undefined;
}): ReactElement => {
const theme = useTheme();
const accepted = format(props);
const [draft, setDraft] = useState(accepted);
useEffect(() => setDraft(accepted), [accepted, nodeId]);
// Reverts on its own so the control goes back to offering the copy rather
// than reporting one indefinitely, and on any edit, because a tick beside
// text that has since changed is a tick about the wrong text.
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return undefined;
const timer = setTimeout(() => setCopied(false), COPIED_FOR_MS);
return () => clearTimeout(timer);
}, [copied]);
useEffect(() => setCopied(false), [draft]);
let parsed: Record<string, unknown> | undefined;
let error: string | undefined;
try {
const value = JSON.parse(draft);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
error = t('Properties must be a JSON object.');
} else {
parsed = value as Record<string, unknown>;
}
} catch (caught) {
error = caught instanceof Error ? caught.message : String(caught);
}
const dirty = draft !== accepted;
return (
<>
{/* Named by the tab it is on, so the label says what these are rather
than repeating how they are being written. */}
<Form.Item
label={t('Properties')}
style={{ marginBottom: theme.sizeUnit * 2 }}
>
<Input.TextArea
size="small"
rows={8}
value={draft}
data-test="inspector-props"
onChange={event => setDraft(event.target.value)}
/>
</Form.Item>
{error !== undefined && (
<p
data-test="inspector-props-error"
style={{
margin: `0 0 ${theme.sizeUnit}px`,
fontSize: theme.fontSizeSM,
color: theme.colorErrorText,
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: theme.sizeUnit }}>
<Button
buttonSize="xsmall"
buttonStyle="primary"
data-test="inspector-props-apply"
disabled={parsed === undefined || !dirty}
onClick={() => {
if (parsed === undefined) {
return;
}
const removed = Object.keys(props ?? {}).filter(
key => !(key in parsed!),
);
provider.updateProps(nodeId, {
...parsed,
...Object.fromEntries(removed.map(key => [key, undefined])),
});
}}
>
{t('Apply')}
</Button>
{/* `secondary` beside the primary Apply — the pairing this app uses
wherever one button commits and the one next to it does not. Two
`primary` buttons side by side say both are the thing to press. */}
<Button
buttonSize="xsmall"
buttonStyle="secondary"
data-test="inspector-props-revert"
disabled={!dirty}
onClick={() => setDraft(accepted)}
>
{t('Revert')}
</Button>
{/* Set apart from the two beside it, because it is not one of them:
those commit what is in the box and this only takes a copy of it.
The draft rather than what the block holds, so what is copied is
what is on screen — including an edit not applied yet.
Confirmed in place: a panel this narrow has nowhere to put a
message, and a copy that says nothing leaves you pressing it
again to be sure. */}
<Button
buttonSize="xsmall"
buttonStyle="link"
data-test="inspector-props-copy"
aria-label={t('Copy properties as JSON')}
tooltip={copied ? t('Copied') : t('Copy properties as JSON')}
placement="bottom"
style={{ marginLeft: 'auto' }}
onClick={() => {
copyTextToClipboard(() => Promise.resolve(draft));
setCopied(true);
}}
>
{copied ? (
<Icons.CheckOutlined iconSize="s" />
) : (
<Icons.CopyOutlined iconSize="s" />
)}
</Button>
</div>
</>
);
};
/**
* The two ways into one set of properties.
*
* They are not alternatives so much as halves. The JSON side is the whole
* record as text: it is the only one that can add a key or drop one, and the
* only one that can express a value no field knows how to hold. The form side
* is generated from the values that are already there (see
* `inferPropsSchema`), so it cannot invent a key — but it is where a value is
* actually filled in, with a control that suits its type instead of quoting
* and escaping inside a string.
*
* The form comes first and is what the panel opens on: it is the half that
* asks a question rather than handing over a record to edit, and most of what
* an author does here is change a value that already exists. The one case it
* cannot serve — a block placed a moment ago, with no properties and so no
* fields — says so and names the tab that can, rather than leaving a blank
* pane that reads as broken.
*
* Only the JSON half is wrapped in an antd `Form`, and the asymmetry is load
* bearing rather than an oversight. The generated controls render their own
* `Form.Item name={...}`, and an antd `Form` above them binds those items to
* its store — which means antd supplies the `value` and the `onChange`,
* overriding the ones JsonForms passed. The field still accepts typing; the
* edit just goes into a form store nothing reads instead of into the block.
* `SemanticLayerModal` renders JsonForms under a plain `<form>` element for
* the same reason.
*/
const PropsEditor = ({
nodeId,
props,
}: {
nodeId: string;
props: Record<string, unknown> | undefined;
}): ReactElement => {
const theme = useTheme();
// Set down from the tab bar, the same step the panel and the palette take
// from theirs. Flush against it, whichever label comes first reads as a
// caption belonging to the tabs rather than as the head of the field under
// it — and on the JSON side that label is the one word saying what the box
// beneath it holds.
const inset = { paddingTop: theme.sizeUnit * 3 };
return (
<Tabs
size="small"
defaultActiveKey="form"
data-test="inspector-props-tabs"
items={[
{
key: 'form',
label: t('Form'),
children: (
<div style={inset}>
<PropsForm nodeId={nodeId} props={props} />
</div>
),
},
{
key: 'json',
label: t('JSON'),
children: (
<Form
layout="vertical"
component="div"
style={inset}
data-test="inspector-props-json"
>
<PropsJsonEditor nodeId={nodeId} props={props} />
</Form>
),
},
]}
/>
);
};
/**
* Property editing over the selected node.
*
* Every field writes through `updateLayout`/`updateProps` — the same two
* calls the AI client tools make — so a change made here and one asked for in
* chat are the same edit arriving by different routes, and neither has a path
* of its own to keep correct.
*
* The Inspector holds no state the store does not: what it shows is read on
* each render, so an assistant edit updates it like anything else.
*/
export default function Inspector(): ReactElement {
useDashboardRevision();
const theme = useTheme();
const selection = provider.getSelection();
const node =
selection === undefined ? undefined : provider.getNode(selection);
// Set down from the tab bar above. Whatever comes first here — the
// identity of what is selected, or the line saying nothing is — reads as a
// caption hanging off the tabs when it starts flush against them.
const inset = { paddingTop: theme.sizeUnit * 3 };
if (!node) {
return (
<div data-test="inspector-empty" style={inset}>
<EmptyState
size="small"
image="empty.svg"
title={t('Nothing selected')}
description={t(
'Pick a block on the canvas, or a row in the Outline, to edit it here.',
)}
/>
</div>
);
}
// The root is the dashboard rather than a block on it, so what it is asked
// for is different in kind: what it is called, who it belongs to, how it
// looks — not where it sits or what it renders.
const isRoot = node.id === provider.getRoot().id;
const content = node.props?.content;
// Offered for a block whose renderer reads prose, whether or not it has
// any yet — a markdown block placed a moment ago has no props at all, and
// waiting for a `content` key to exist before showing the field is what
// left it with no way to be given one.
const takesText =
typeof content === 'string' || PLAIN_TEXT_CONTENT.has(node.type);
return (
<div data-test="inspector" style={{ ...inset, fontSize: theme.fontSizeSM }}>
{isRoot ? (
<DashboardProperties />
) : (
// The same shape the dashboard's own panel opens with: what this is,
// then the smaller print about it. The name is `blockLabel`'s — the
// one the canvas header and the Outline row already use — so a block
// is called one thing in all three places, and the type and id sit
// under it as what they are, a fact about the block rather than its
// name.
<div data-test="inspector-identity">
<IdentityName>{blockLabel(node.type, node.props)}</IdentityName>
<IdentityMeta>
{node.type} · {node.id}
</IdentityMeta>
</div>
)}
{/* Outside the `Form` below, and each half of it wrapping its own —
the generated form must not have an antd `Form` above it. See
`PropsEditor`. */}
{!isRoot && (
<Section title={t('Content')} test="inspector-section-content">
{takesText && (
<Form layout="vertical" component="div">
<ContentField
nodeId={node.id}
content={typeof content === 'string' ? content : ''}
/>
</Form>
)}
<PropsEditor nodeId={node.id} props={node.props} />
</Section>
)}
{/* The root is placed by nothing — it is what everything else is
placed in — so it has no column, row or span of its own to set. */}
{!isRoot && (
<Form layout="vertical" component="div">
<Section title={t('Placement')} test="inspector-section-placement">
{CHILD_FIELDS.map(field => (
<NumberField
key={field.key}
label={field.label}
test={`inspector-${field.key}`}
value={node.layout?.[field.key] as number | undefined}
onChange={next =>
provider.updateLayout(node.id, { [field.key]: next })
}
/>
))}
</Section>
</Form>
)}
{/* `removeBuildingBlock` refuses the root, so offering it here would be
a button that only ever raises.
Ruled off from the fields above it rather than following them at a
gap: everything else in this column changes the block, and this is
the one control that ends it. The rule is the same one that divides
the sections, so the panel reads as ending here rather than as
having one more field. */}
{!isRoot && (
<Footer>
<Button
buttonSize="xsmall"
// `buttonStyle`, not antd's own `danger`: the shared Button reads
// the former and derives the latter from it, so a bare `danger`
// is dropped and the control falls back to `primary` — which drew
// the one destructive thing in this panel as its filled headline
// action.
buttonStyle="danger"
icon={<Icons.DeleteOutlined iconSize="s" />}
data-test="inspector-delete"
onClick={() => provider.removeBuildingBlock(node.id)}
>
{t('Delete block')}
</Button>
</Footer>
)}
</div>
);
}
@@ -0,0 +1,122 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
import 'src/core/dashboard';
import Outline from './Outline';
const provider = DashboardProvider.getInstance();
/**
* Which elements were scrolled to, in order. jsdom has no layout, so the call
* is the only observable part of scrolling — what it was called on is what
* says the right block was reached for.
*/
const scrolled: Element[] = [];
beforeEach(() => {
provider.reset();
scrolled.length = 0;
Element.prototype.scrollIntoView = jest.fn(function record(this: Element) {
scrolled.push(this);
});
});
/** The outline next to the canvas it reaches into: the pairing under test. */
const mount = () =>
render(
<>
<Outline />
<BuildingBlockView nodeId={provider.getRoot().id} />
</>,
);
const addMarkdown = (content: string): string =>
provider.addBuildingBlock(provider.getRoot().id, 0, {
type: 'markdown',
props: { content },
});
test('an empty dashboard says so instead of showing an empty tree', () => {
mount();
expect(screen.getByTestId('outline-empty')).toBeInTheDocument();
});
test('a row is listed for every block, labelled by its content', () => {
const id = addMarkdown('Revenue by region');
mount();
// Scoped to the row rather than looked for on the page: the block's own
// header on the canvas carries the same name, by design, so a bare text
// query would match twice and prove neither.
expect(screen.getByRole('tree')).toBeInTheDocument();
expect(screen.getByTestId(`outline-row-${id}`)).toHaveTextContent(
'Revenue by region',
);
});
test('choosing a row selects that block', async () => {
const id = addMarkdown('Revenue by region');
mount();
await userEvent.click(screen.getByTestId(`outline-row-${id}`));
expect(provider.getSelection()).toBe(id);
});
test('choosing a row brings its block into view on the canvas', async () => {
const below = addMarkdown('Down the page');
addMarkdown('Up the top');
const { container } = mount();
await userEvent.click(screen.getByTestId(`outline-row-${below}`));
// The point of the outline is reaching blocks the canvas is worst at
// offering — including one scrolled out of sight. Selecting it and leaving
// it off screen marks a block the author cannot see.
expect(scrolled).toEqual([
container.querySelector(`[data-node-id="${below}"]`),
]);
});
test('the keyboard reaches a block the same way the pointer does', () => {
const id = addMarkdown('Revenue by region');
const { container } = mount();
fireEvent.keyDown(screen.getByTestId(`outline-row-${id}`), { key: 'Enter' });
expect(provider.getSelection()).toBe(id);
expect(scrolled).toEqual([container.querySelector(`[data-node-id="${id}"]`)]);
});
test('a row whose block is not on screen still selects', async () => {
// The outline can outlive the canvas it describes — rendered on its own
// here, but equally a block behind a collapsed container. Selection is the
// part that must not depend on finding an element to scroll.
const id = addMarkdown('Revenue by region');
render(<Outline />);
await userEvent.click(screen.getByTestId(`outline-row-${id}`));
expect(provider.getSelection()).toBe(id);
expect(scrolled).toEqual([]);
});
@@ -0,0 +1,299 @@
/**
* 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 { EmptyState } from '@superset-ui/core/components';
import { views } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
import { provider, useDashboardRevision } from 'src/core/dashboard/store';
/** How long a label may run before it is cut. */
const LABEL_LIMIT = 40;
/**
* What a node is called in the outline.
*
* A registered block's own name first, because that is what the author chose
* it by in the palette. Markdown gets its opening words instead — a list of
* five rows all reading "Markdown" identifies nothing, and the content is the
* only thing that tells them apart.
*/
const labelOf = (type: string, props: Record<string, unknown> | undefined) => {
const content = props?.content;
if (typeof content === 'string' && content.trim() !== '') {
const text = content.trim().replace(/\s+/g, ' ');
return text.length > LABEL_LIMIT ? `${text.slice(0, LABEL_LIMIT)}` : text;
}
const registered = views
.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION)
?.find(view => view.id === type);
return registered?.name ?? type;
};
/**
* Selects a node and shows it where it lives.
*
* Marking a block as selected is only half of reaching it: the rows this
* panel exists for are the ones for blocks the canvas is currently not
* offering, and an outline that selected something off screen would leave an
* author looking at a canvas that appears not to have answered. The block's
* own element carries `data-node-id` (see `BuildingBlockView`), so the canvas
* needs no wiring back to here.
*
* `nearest` rather than `center`: this fires on every row, and reading down a
* list of blocks that are already in view should not move the canvas under
* them. Nothing happens at all when the element is absent — a node can be in
* the tree without being rendered — and selection has already been set by
* then either way.
*/
const select = (nodeId: string): void => {
provider.setSelection(nodeId);
document
.querySelector(`[data-node-id="${nodeId}"]`)
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
};
/**
* A node, as a tile to read and to reach through.
*
* The same tile the palette is built from, because the two panels are the same
* kind of thing seen twice: a tree of blocks, one of blocks you could place
* and one of blocks you did. A block that is a bordered tile with a name in
* the Building blocks tab and a bare line of text in the Outline reads as two
* different kinds of object.
*
* What it does not borrow is the grip: these do not drag. What it adds is
* selection, which the palette has no equivalent of — the accent border and
* fill, kept through hover so a pointer passing over the selected tile does
* not read as unselecting it.
*/
const OutlineTile = styled.div<{ $selected: boolean }>`
${({ theme, $selected }) => css`
position: relative;
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 2}px;
border: 1px solid ${$selected ? theme.colorPrimary : theme.colorBorder};
border-radius: ${theme.borderRadiusSM}px;
background-color: ${
$selected ? theme.colorPrimaryBg : theme.colorFillQuaternary
};
font-size: ${theme.fontSizeSM}px;
color: ${$selected ? theme.colorPrimaryText : theme.colorText};
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
border-color ${theme.motionDurationMid},
background-color ${theme.motionDurationMid};
&:hover {
border-color: ${
$selected ? theme.colorPrimary : theme.colorPrimaryBorderHover
};
background-color: ${
$selected ? theme.colorPrimaryBgHover : theme.colorFillTertiary
};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimaryBorder};
outline-offset: -2px;
}
`}
`;
/** The tree itself: the reset, and the space between what is at its top. */
const List = styled.ul`
${({ theme }) => css`
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit}px;
`}
`;
/**
* A node's children, and the guide that says they are its.
*
* The same treatment the palette gives a shelf, for the same reason and at the
* same measurements: indentation alone leaves the eye to infer the grouping
* from an edge that is not drawn, and here the nesting can run deeper than the
* palette's single level, so there is that much more to infer.
*
* Drawn from here rather than from the tile, because unlike the palette a tile
* in this tree may itself hold a branch — and the guide has to clear that
* whole subtree to reach the sibling below it. So the vertical is drawn on the
* list item, which is the tile *and* everything under it; only the last item
* draws it on its own tile instead, stopping at the stub. A guide that carries
* on past the final tile reads as a branch with something still to come; one
* drawn on every tile would break wherever a node had children.
*/
const Branch = styled(List)`
${({ theme }) => css`
margin-top: ${theme.sizeUnit}px;
margin-left: ${theme.sizeUnit * 2}px;
padding-left: ${theme.sizeUnit * 3}px;
& > li {
position: relative;
}
/* The vertical, past everything this item holds, to the one below it. */
& > li:not(:last-child)::before,
/* The last item's, stopping where its own stub meets it. */
& > li:last-child > [role='treeitem']::before,
/* Every item's stub back to the guide. */
& > li > [role='treeitem']::after {
content: '';
position: absolute;
left: -${theme.sizeUnit * 3}px;
background-color: ${theme.colorBorder};
}
& > li:not(:last-child)::before {
top: -${theme.sizeUnit}px;
bottom: -${theme.sizeUnit}px;
width: 1px;
}
& > li:last-child > [role='treeitem']::before {
top: -${theme.sizeUnit}px;
bottom: 50%;
width: 1px;
}
& > li > [role='treeitem']::after {
top: 50%;
width: ${theme.sizeUnit * 3}px;
height: 1px;
}
`}
`;
/**
* Set down from the tab bar and in from the panel edge, the same step the
* palette and the inspector take from theirs. Flush against the tabs, the
* first row read as a caption hanging off them rather than as the top of a
* list — and the three tabs of one panel should start on one line.
*/
const Panel = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
`}
`;
const Row = ({
nodeId,
depth,
}: {
nodeId: string;
depth: number;
}): ReactElement | null => {
const node = provider.getNode(nodeId);
if (!node) {
return null;
}
const selected = provider.getSelection() === nodeId;
const children = node.children ?? [];
return (
<li role="none">
<OutlineTile
role="treeitem"
aria-level={depth + 1}
aria-selected={selected}
tabIndex={selected ? 0 : -1}
data-test={`outline-row-${nodeId}`}
$selected={selected}
onClick={() => select(nodeId)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
select(nodeId);
}
}}
>
{labelOf(node.type, node.props)}
</OutlineTile>
{children.length > 0 && (
<Branch
// The tags the rule suggests are document sections, not tree
// structure. `group` inside `tree` is the pattern WAI-ARIA
// specifies for a treeitem's children, and a screen reader's tree
// navigation reads it — no semantic element means this.
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="group"
>
{children.map(childId => (
<Row key={childId} nodeId={childId} depth={depth + 1} />
))}
</Branch>
)}
</li>
);
};
/**
* The dashboard's structure, as something to read and to reach into.
*
* The canvas shows what a dashboard looks like; this shows what it is made
* of. That matters most for exactly the blocks the canvas is worst at
* offering — one nested inside a container, one scrolled out of view, one
* sized so small there is nothing to click.
*
* Choosing a row selects it and leaves the author here. Reading a structure
* means going through it, and a panel that ejected to Properties on the first
* row would hide the very row it had just marked as selected.
*/
export default function Outline(): ReactElement {
useDashboardRevision();
const root = provider.getRoot();
const children = root.children ?? [];
if (children.length === 0) {
return (
<Panel data-test="outline-empty">
<EmptyState
size="small"
image="empty-dashboard.svg"
title={t('Nothing on the dashboard yet')}
description={t(
'Blocks you place show up here, in the order they sit on the canvas.',
)}
/>
</Panel>
);
}
return (
<Panel>
<List role="tree" aria-label={t('Dashboard outline')} data-test="outline">
{children.map(childId => (
<Row key={childId} nodeId={childId} depth={0} />
))}
</List>
</Panel>
);
}
@@ -0,0 +1,446 @@
/**
* 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 { 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 { useViews } from 'src/core/views';
import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView';
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.
*
* Derived from the one distinction this fork actually records: whether
* placing the type produces something other blocks can go inside. That is a
* checkable property of the node the provider builds, not a category anybody
* maintains, so a block registered by an extension tomorrow is shelved
* correctly without this file learning its name.
*
* There is deliberately no Extensions shelf. A registered `View` carries an
* id, a name and a description and nothing that says who contributed it, so
* built-in and extension-contributed blocks are genuinely indistinguishable
* here. Splitting them on a dotted-id naming convention would be a guess
* dressed as a fact; the shelf can be added the day provenance is.
*/
const SHELVES: readonly {
readonly key: 'structure' | 'content';
readonly name: string;
}[] = [
{ key: 'structure', name: t('Structure') },
{ key: 'content', name: t('Content') },
];
export interface PaletteEntry {
readonly type: string;
readonly label: string;
readonly description?: string;
readonly shelf: '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 === '') {
return true;
}
const needle = query.toLowerCase();
return (
entry.label.toLowerCase().includes(needle) ||
(entry.description ?? '').toLowerCase().includes(needle)
);
};
/**
* The panel's own scroll column.
*
* The search field stays put and the shelves move under it: a list long enough
* to scroll is exactly when the field that filters it must not scroll away.
*/
const Column = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
/* The field is not the first item of the list it filters, and at a tighter
gap it read as one — the shelf below it sat as close to it as its own
tiles sit to each other. The space is what separates searching the
palette from reading it. */
gap: ${theme.sizeUnit * 5}px;
min-height: 0;
/* Set down from the tab bar and in from the panel edge: a search field
flush against both reads as part of the chrome around the list rather
than the way into it. */
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
`}
`;
const Shelves = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit}px;
overflow-y: auto;
min-height: 0;
`}
`;
/**
* A shelf's name, heavier than what is on the shelf.
*
* Set in the secondary colour it came out lighter than the rows beneath it,
* which reads as the shelf belonging to the list rather than the list to the
* shelf. Same size as its rows and heavier, the same trade the Inspector's
* section headings make.
*
* The hover is a wash rather than the fill a row takes, because a shelf that
* lights the way a row lights is a row: this one opens and closes a group, and
* should not look like something to place.
*/
const ShelfButton = styled.button`
${({ theme }) => css`
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
width: 100%;
padding: ${theme.sizeUnit}px;
border: 0;
border-radius: ${theme.borderRadiusSM}px;
background: none;
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
font-weight: ${theme.fontWeightStrong};
text-align: left;
cursor: pointer;
transition: background-color ${theme.motionDurationMid};
/* The toggle is the shelf's state made visible — plus for shut, minus for
open — and it is quieter than the name it sits beside, which is what is
actually being read. */
.palette-toggle {
display: flex;
flex: 0 0 auto;
color: ${theme.colorTextTertiary};
}
&:hover {
background-color: ${theme.colorFillQuaternary};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimaryBorder};
outline-offset: -2px;
}
`}
`;
/**
* What ties a shelf to the blocks on it.
*
* The tiles are indented under their shelf, and indentation alone leaves the
* eye to infer the grouping from an edge that is not drawn. The guide down the
* left is that edge, and each tile reaches back to it with a stub — so a tile
* belongs to the shelf above it visibly rather than by inference.
*
* The guide is drawn by the tiles rather than here (see `BlockTile`), because
* where it has to stop is the middle of the last tile and this element cannot
* know where that is. Drawn in `colorBorder`, which is what this app draws one
* thing off from another with — the same one the tiles are drawn with, so the
* guide and what it holds are one weight of line.
*/
const Branch = styled.div`
${({ theme }) => css`
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit}px;
margin-left: ${theme.sizeUnit * 2}px;
padding-left: ${theme.sizeUnit * 3}px;
`}
`;
/**
* A block, as a tile to pick up.
*
* A bordered tile rather than a bare row: what these are is a set of things
* that get dragged onto a canvas and become boxes there, and a tile with an
* edge is a thing you can take hold of in a way a line of text is not. The
* grip states the same thing in the same place on every one of them.
*
* The stub reaching left is what joins the tile to its shelf's guide — see
* `Branch`. It is drawn from the tile rather than by the shelf because only
* the tile knows where its own middle is.
*
* `grab` becoming `grabbing`, and the border taking the accent under the
* pointer, are the two halves of saying this can be dragged. The focus ring is
* the same answer for a keyboard, which the row had no visible reply to at all.
* Every colour here is a token: the tile has to hold up in both themes, and a
* literal only ever suits the one it was picked in.
*/
const BlockTile = styled.button`
${({ theme }) => css`
position: relative;
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
width: 100%;
padding: ${theme.sizeUnit * 2}px;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadiusSM}px;
background-color: ${theme.colorFillQuaternary};
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
text-align: left;
cursor: grab;
transition:
border-color ${theme.motionDurationMid},
background-color ${theme.motionDurationMid};
/* The shelf's guide, and this tile's stub back to it.
The vertical runs from above the tile — bridging the gap to the one
before it — down to the tile's own bottom, so the segments meet and read
as one line. The last tile stops it at the stub: a guide that carries on
past the final tile reads as a shelf with something still to come. */
&::before,
&::after {
content: '';
position: absolute;
left: -${theme.sizeUnit * 3}px;
background-color: ${theme.colorBorder};
}
&::before {
top: -${theme.sizeUnit}px;
bottom: 0;
width: 1px;
}
&:last-child::before {
bottom: 50%;
}
&::after {
top: 50%;
width: ${theme.sizeUnit * 3}px;
height: 1px;
}
&:hover {
border-color: ${theme.colorPrimaryBorderHover};
background-color: ${theme.colorFillTertiary};
}
&:active {
cursor: grabbing;
border-color: ${theme.colorPrimary};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimaryBorder};
outline-offset: -2px;
}
/* The grip is part of the tile's answer rather than a control of its own,
so it strengthens with the tile rather than on its own hover. */
.palette-grip {
display: flex;
flex: 0 0 auto;
color: ${theme.colorTextQuaternary};
transition: color ${theme.motionDurationMid};
}
&:hover .palette-grip,
&:focus-visible .palette-grip {
color: ${theme.colorTextTertiary};
}
`}
`;
/**
* A disclosure the palette drives rather than the browser, so a search can
* reveal through a shelf the author collapsed and give it back on clearing.
*/
const Disclosure = ({
name,
open,
onToggle,
children,
}: {
name: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}): ReactElement => (
<div data-test={`palette-shelf-${name.toLowerCase()}`}>
<ShelfButton
type="button"
aria-expanded={open}
// The toggle carries an `aria-label` of its own, which would otherwise
// join the shelf's name and announce the shape of the glyph first.
aria-label={name}
onClick={onToggle}
>
<span className="palette-toggle" aria-hidden>
{open ? (
<Icons.MinusSquareOutlined iconSize="s" />
) : (
<Icons.PlusSquareOutlined iconSize="s" />
)}
</span>
{name}
</ShelfButton>
{open && <Branch>{children}</Branch>}
</div>
);
/**
* The building blocks, as things to place.
*
* The list is the registry's — `useViews('dashboard.buildingBlocks')`, the
* same location `BuildingBlockView` resolves a renderer through for anything
* other than the root. Registering a block makes it placeable and
* 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,
}: {
onAdd: (type: string) => void;
}): ReactElement {
const [query, setQuery] = useState('');
const [closed, setClosed] = useState<ReadonlySet<string>>(new Set());
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);
const toggle = (key: string): void =>
setClosed(previous => {
const next = new Set(previous);
if (!next.delete(key)) {
next.add(key);
}
return next;
});
return (
<Column data-test="palette">
{/* At the default height rather than `small`. This is the way into the
whole palette and the only thing on the tab that is typed into, and
at the smallest step it was shorter than the tiles it filters — the
one control on the panel read as the least of them. */}
<Input
allowClear
value={query}
aria-label={t('Search components')}
placeholder={t('Search components…')}
data-test="palette-search"
prefix={<Icons.SearchOutlined iconSize="s" />}
onChange={event => setQuery(event.target.value)}
/>
{found.length === 0 ? (
<div data-test="palette-empty">
<EmptyState
size="small"
image="filter-results.svg"
title={t('No matching blocks')}
description={t('Nothing here is called “%s”.', query)}
/>
</div>
) : (
<Shelves>
{SHELVES.map(shelf => {
const onShelf = found.filter(entry => entry.shelf === shelf.key);
// An empty shelf is not a shelf: it would imply something failed
// to register rather than that nothing of that kind exists.
if (onShelf.length === 0) {
return null;
}
return (
<Disclosure
key={shelf.key}
name={shelf.name}
open={isOpen(shelf.key)}
onToggle={() => toggle(shelf.key)}
>
{onShelf.map(entry => (
<BlockTile
key={entry.type}
type="button"
draggable
title={entry.description}
data-test={`palette-${entry.type}`}
onClick={() => onAdd(entry.type)}
// The grip beside the label promised this and did not
// deliver it: the tiles carried the affordance of a drag
// without the drag. Clicking still appends to whatever is
// selected; dragging is how an author says *where*.
onDragStart={event => {
event.dataTransfer.setData(PALETTE_MIME, entry.type);
event.dataTransfer.effectAllowed = 'copy';
}}
>
{/* Beside what it drags rather than at the far edge of
the tile: the panel is resizable, and a handle pinned
right drifts further from its label the wider it is
pulled. Decoration — the tile already carries the name,
so announcing the grip again would only repeat it. */}
<span className="palette-grip" aria-hidden>
<Icons.HolderOutlined iconSize="s" />
</span>
{entry.label}
</BlockTile>
))}
</Disclosure>
);
})}
</Shelves>
)}
</Column>
);
}
@@ -0,0 +1,272 @@
/**
* 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 { useMemo } from 'react';
import type { ReactElement } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { JsonForms } from '@jsonforms/react';
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
import { renderers } from 'src/features/semanticLayers/jsonFormsHelpers';
import { provider } from 'src/core/dashboard/store';
import inferPropsSchema, { untypedKeys } from './inferPropsSchema';
/**
* What the generated form is made to agree with.
*
* The controls come from a third-party renderer set, and it lays a form out
* for a page of its own rather than for a rail beside a canvas. Four things
* came out of it not matching the panel around them, and none of them can be
* fixed at the call site because nothing here renders the controls:
*
* - a group's name arrives as a bare `b` with no size, weight or space of its
* own, so it read as the run-on end of the field above rather than as the
* head of the group below. It is given `Section`'s heading, which is what a
* group of fields is titled with everywhere else in this panel.
* - controls are sized by what they hold: some carry `width: 100%`, some sit
* in an auto-width column. A column of fields that steps in and out down the
* panel reads as broken before it reads as compact, so they are all told to
* fill the column.
* - what adds a row to an array sits in a list footer, which antd centres,
* while what deletes one is pushed right — so two controls doing the same
* kind of job to the same array sat at opposite ends of it. Both go left,
* where every other control in this rail starts.
* - the buttons are antd's own default, which is `tertiary` in this app's
* terms and the right style for them; what they are not is the height the
* rest of the rail is at, and a form of full-height buttons inside a panel
* of small ones is the join showing.
*
* Scoped to this element rather than fixed in the renderers, which the
* semantic-layer modal also draws from and which are not this change's to move
* — the same reason `DashboardProperties` scopes its own input fix.
*/
const FormShell = styled.div`
${({ theme }) => css`
/* A group's name, at the weight Section titles a group with. */
> form > b,
fieldset > b {
display: block;
margin: ${theme.sizeUnit * 4}px 0 ${theme.sizeUnit * 2}px;
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
}
/* One column, one width.
An array's entries are handed to a grid meant for a page — two to a
line, so a dimension came out half the width of the field above it. In a
rail there is no second column to put anything in, so the grid is turned
down its own axis and every cell given the width. The form item's own
label/control row is left alone: it is already a column in this layout,
and it is not a grid of entries. */
.ant-form-item-control-input-content .ant-row:not(.ant-form-item-row) {
flex-direction: column;
align-items: stretch;
}
.ant-form-item-control-input-content > .ant-col,
.ant-form-item-control-input-content
.ant-row:not(.ant-form-item-row)
> .ant-col {
flex: 1 1 auto;
min-width: 0;
width: 100%;
max-width: 100%;
}
.ant-input,
.ant-input-number,
.ant-picker,
.ant-select {
width: 100%;
}
/* One entry of an array: its fields down the column, and what removes it
beneath them.
antd lays a list item as a row and pushes its actions to the far end, so
the fields of an entry shared the width with a Delete button and came
out a hundred pixels narrower than the fields around them — the only
reason "Column Name" sat short of "Dataset Id". Stacked, the fields get
the column and the button falls under them at the start, which is where
the other thing that acts on this array already is. */
.ant-list-item {
flex-direction: column;
align-items: stretch;
gap: ${theme.sizeUnit}px;
padding-inline: 0;
}
/* Written at antd's own depth, and doubled.
Two things have to be beaten here. antd indents the actions with
margin-inline-start, which a physical margin-left does not compete with;
and it says so through a selector wrapped in :where(), which counts for
nothing and leaves three classes — more than this element plus its own
class, until the rule is written out this long. The indent is meant for
a list of actions on a page-wide row; on one Delete under a field it is
a step with nothing to line up against. */
&& .ant-list .ant-list-item .ant-list-item-action {
margin-inline-start: 0;
padding-inline: 0;
text-align: left;
}
&& .ant-list .ant-list-item .ant-list-item-action > li {
padding-inline: 0;
}
/* Whatever acts on an array, at the start of it. What adds an entry is
handed to a centred flex row and what removes one to a list action, so
the two controls doing the same kind of job to the same array sat at
opposite ends of it. */
.ant-list-footer,
.ant-list-header {
padding-inline: 0;
text-align: left;
}
.ant-flex-justify-center,
.ant-form-item-control-input-content > .ant-row {
justify-content: flex-start;
}
/* At the rail's own control height, like every button beside it. */
.ant-btn {
height: ${theme.controlHeightSM}px;
font-size: ${theme.fontSizeSM}px;
}
/* One rhythm down the column: the renderers space their own items and
their dividers, and the two scales did not agree. */
.ant-form-item {
margin-bottom: ${theme.sizeUnit * 2}px;
}
.ant-divider-horizontal {
margin: ${theme.sizeUnit * 3}px 0;
}
`}
`;
/**
* A block's properties as fields, generated from the values it holds.
*
* The other half of this panel edits the same properties as JSON, and the two
* divide cleanly: JSON is where the *shape* is decided — a key added, a key
* dropped — and this is where the values in that shape are filled in. That is
* not a limitation to work around but what a generated form is: with no schema
* shipped alongside a block's registration (see `inferPropsSchema`), a field
* can only exist where a value already does.
*
* Edits are written as they are made rather than held until focus leaves.
* JsonForms already debounces what it reports by 10ms, and that debounce is
* exactly what a commit on blur races: clicking away fires the blur first and
* commits the draft as it stood a moment before the last keystroke, which
* silently drops it. Writing from `onChange` has one ordering and no draft to
* fall behind.
*/
export default function PropsForm({
nodeId,
props,
}: {
nodeId: string;
props: Record<string, unknown> | undefined;
}): ReactElement {
const theme = useTheme();
// Compared by value rather than by identity: `props` is a fresh object on
// every render of the panel, so anything derived from it has to be keyed on
// what it says rather than on which object it is, or the form is rebuilt
// under the cursor on every unrelated tick of the store.
const accepted = JSON.stringify(props ?? {});
const data = useMemo(
() => JSON.parse(accepted) as Record<string, unknown>,
[accepted],
);
const schema = useMemo(() => inferPropsSchema(data), [data]);
const untyped = useMemo(() => untypedKeys(data), [data]);
const empty = Object.keys(schema.properties ?? {}).length === 0;
const note = (text: string) => (
<p
style={{
margin: 0,
color: theme.colorTextTertiary,
fontSize: theme.fontSizeSM,
}}
>
{text}
</p>
);
return (
<FormShell
data-test="inspector-props-form"
// Labels above their fields, as everywhere else in this rail — beside
// them halves the width left for the control, in the panel that most
// needs the room.
//
// Said in classes rather than by wrapping this in an antd `Form`,
// because that is what the renderers read: `useParentFormLayout` takes
// the layout off the nearest `.ant-form` ancestor's class, by its own
// account, precisely so it does not depend on antd's form context. A
// real `Form` here would set the layout and take the edits with it —
// see `PropsEditor`.
className="ant-form ant-form-vertical"
>
{empty
? note(
t(
'This block has no properties yet. Add them on the JSON tab, and they become fields here.',
),
)
: /* No `uischema`: JsonForms lays out whatever the schema describes,
which is the point of generating the schema in the first place. */
null}
{!empty && (
<JsonForms
schema={schema}
data={data}
renderers={renderers}
cells={cellRegistryEntries}
// Nothing here is required and nothing is constrained, because the
// schema was read off values a block already renders from — so a
// validation message could only ever be about a type this form
// itself assigned.
validationMode="NoValidation"
onChange={({ data: next }) => {
// Guarded because this fires on mount with what was passed in,
// and again with the value that has just been written — neither
// is an edit, and both would otherwise tick the store.
if (JSON.stringify(next) !== accepted) {
provider.updateProps(nodeId, next as Record<string, unknown>);
}
}}
/>
)}
{untyped.length > 0 &&
note(
t(
'Only editable as JSON, having no value to take a type from: %s',
untyped.join(', '),
),
)}
</FormShell>
);
}
@@ -0,0 +1,237 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import DashboardProvider from 'src/core/dashboard/DashboardProvider';
import DashboardBuilderV2 from '.';
const provider = DashboardProvider.getInstance();
beforeEach(() => {
provider.reset();
});
const renderPage = () => render(<DashboardBuilderV2 />, { useRedux: true });
test('a blank dashboard can still be reached', async () => {
renderPage();
// The canvas is no longer chat-only: a palette sits beside it, so the
// empty state names both ways in.
expect(
screen.getByText(
'Drag a building block from the panel, or ask the assistant for one.',
),
).toBeInTheDocument();
await userEvent.click(screen.getByTestId('empty-canvas'));
expect(provider.getSelection()).toBe(provider.getRoot().id);
});
test('the canvas has no corner controls', () => {
renderPage();
expect(screen.queryByTestId('canvas-controls')).not.toBeInTheDocument();
expect(screen.queryByTestId('canvas-arrange')).not.toBeInTheDocument();
expect(screen.queryByTestId('canvas-refresh')).not.toBeInTheDocument();
});
test('the page is a header, an editor panel and a canvas', () => {
renderPage();
expect(screen.getByTestId('dashboard-header')).toBeInTheDocument();
expect(screen.getByTestId('editor-panel')).toBeInTheDocument();
expect(screen.getByTestId('canvas')).toBeInTheDocument();
});
test('placing a block from the palette puts it on the dashboard and selects it', async () => {
renderPage();
await userEvent.click(screen.getByTestId('palette-markdown'));
const children = provider.getRoot().children ?? [];
expect(children).toHaveLength(1);
// Placing something is the moment you want to configure it, which is also
// what brings Properties forward.
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: '',
};
};
/**
* jsdom has no layout engine — `getBoundingClientRect` on any element
* returns all zeros unless overridden, which is exactly what the preview's
* own cursor-to-cell math (`cellAtPoint`/`resolveCellGeometry`) divides by.
* Stubbed here to a realistic, arbitrary size so that math produces real,
* assertable pixels instead of `NaN`/`Infinity` — the same reason
* `RootGrid.test.tsx` mocks `gridstack` rather than asserting real pixel
* geometry against a real DOM.
*/
function stubCanvasRect(canvas: HTMLElement, width: number, height: number) {
canvas.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
}) as DOMRect;
}
/**
* `fireEvent.dragOver(el, { clientX, clientY })` silently drops both —
* jsdom's `DragEvent` doesn't carry `MouseEvent`'s init properties through
* the way a real browser's does, so `event.clientX`/`clientY` come out
* `undefined` in the handler regardless of what's passed here. A plain
* `MouseEvent` (which jsdom *does* construct correctly) with `dataTransfer`
* attached after the fact gets the real pixel values the preview's own
* cursor-to-cell math needs, without needing an actual `DragEvent`.
*/
function dragOverAt(
el: HTMLElement,
type: string,
clientX: number,
clientY: number,
) {
const event = new MouseEvent('dragover', {
bubbles: true,
cancelable: true,
clientX,
clientY,
});
Object.defineProperty(event, 'dataTransfer', {
value: paletteTransfer(type),
});
fireEvent(el, event);
}
test('the empty-canvas drop preview is sized like the first block, not the whole canvas', () => {
renderPage();
const canvas = screen.getByTestId('empty-canvas');
stubCanvasRect(canvas, 1000, 800);
fireEvent.dragEnter(canvas, { dataTransfer: paletteTransfer('markdown') });
dragOverAt(canvas, 'markdown', 500, 400);
const preview = screen.getByTestId('empty-canvas-drop-preview');
// Regression: this used to be `width: 100%; height: 100%`, so the
// preview always filled the entire canvas regardless of its own size —
// reading as ignoring the drag rather than answering it.
expect(parseFloat(preview.style.width)).toBeGreaterThan(0);
expect(parseFloat(preview.style.width)).toBeLessThan(1000);
expect(parseFloat(preview.style.height)).toBeGreaterThan(0);
expect(parseFloat(preview.style.height)).toBeLessThan(800);
});
test('a drag that ends without ever dropping (cancelled, or released off-canvas) still clears the empty-canvas preview', () => {
renderPage();
const canvas = screen.getByTestId('empty-canvas');
stubCanvasRect(canvas, 1000, 800);
fireEvent.dragEnter(canvas, { dataTransfer: paletteTransfer('markdown') });
dragOverAt(canvas, 'markdown', 500, 400);
expect(screen.getByTestId('empty-canvas-drop-preview')).toBeInTheDocument();
// No `dragleave`, no `drop` — just the drag concluding, the same way a
// release past the browser window's own edge or an `Escape` would.
fireEvent(document, new Event('dragend', { bubbles: true }));
expect(
screen.queryByTestId('empty-canvas-drop-preview'),
).not.toBeInTheDocument();
});
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();
// 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(paneId));
await userEvent.click(screen.getByTestId('palette-markdown'));
// 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 () => {
renderPage();
await userEvent.click(screen.getByTestId('palette-markdown'));
const firstId = provider.getSelection()!;
await userEvent.click(screen.getByTestId('palette-echarts'));
expect(provider.getRoot().children).toEqual([
firstId,
provider.getSelection(),
]);
});
test('clicking the canvas itself clears the selection', async () => {
renderPage();
await userEvent.click(screen.getByTestId('palette-markdown'));
expect(provider.getSelection()).toBeDefined();
await userEvent.click(screen.getByTestId('canvas'));
// A click that reached the canvas passed every block on the way, so it is
// the one gesture that unambiguously means "nothing".
expect(provider.getSelection()).toBeUndefined();
});
@@ -0,0 +1,405 @@
/**
* 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 { useEffect, useRef, useState } from 'react';
import type { DragEvent as ReactDragEvent } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } 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 {
FALLBACK_COL_SPAN,
FALLBACK_ROW_SPAN,
PALETTE_MIME,
placeBlock,
placeBlockAt,
} from 'src/core/dashboard/placement';
import {
cellAtPoint,
pixelRectForCell,
resolveCellGeometry,
resolveGridMetrics,
} from 'src/core/dashboard/layoutStyle';
import { availableDropSpan } from 'src/core/dashboard/gridPacking';
import type { PackedRect } from 'src/core/dashboard/gridPacking';
import BuildingBlockView from 'src/core/dashboard/BuildingBlockView';
import DashboardHeader from './DashboardHeader';
import EditorPanel from './EditorPanel';
const PageContainer = styled(Flex)`
${({ theme }) => css`
flex: 1 1 auto;
height: 100%;
min-height: 0;
background-color: ${theme.colorBgLayout};
`}
`;
/**
* The one scrollable region for the whole editor — deliberately the only
* `overflow: auto` in this ancestor chain. `RootGrid`'s own surface grows to
* fit its content instead of scrolling internally (see its `GridSurface`
* doc comment); two nested scroll containers fighting over the same mouse
* wheel input reads as broken scrolling, not as "the canvas is tall, scroll
* it". `overflow-x: hidden` guards the same race `GridSurface` used to guard
* before the scroll moved here: the live drop preview can briefly push the
* grid's own content height past the visible viewport, forcing this
* scrollbar to appear — and this element's own width measurement can lag
* that by a frame, rendering momentarily wider than the space the new
* scrollbar just took back. Horizontal scrolling was never a legitimate
* state for this canvas anyway (columns are fractional and reflow to
* width), so this just closes off the one axis where that lag could ever
* become visible.
*/
const Canvas = styled.div`
${({ theme }) => css`
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: ${theme.paddingLG}px;
`}
`;
const Workspace = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
`;
const EmptyCanvasWrapper = styled.div`
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`;
/**
* The dashboard with nothing on it, as something to aim at.
*
* No border and no hover fill of its own — the root draws directly onto the
* grid, same as every block on it (see `BuildingBlockView`) — but it is
* 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.
*
* A plain `styled.div`, deliberately not `styled(Flex)` (antd's `Flex` isn't
* wrapped in `forwardRef`, so a `ref` on it silently never attaches to any
* real DOM node — every width-dependent measurement below would read `0`
* forever, with nothing to warn about it other than a stray console message
* easy to miss) — `DropPreview`'s own cursor-following math reads this
* element's real width every render, and there is no reasonable substitute
* to measure instead.
*/
const CanvasPlaceholder = styled.div`
${({ theme }) => css`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: ${theme.borderRadiusLG}px;
color: ${theme.colorTextTertiary};
cursor: pointer;
&:focus-visible {
outline: 2px solid ${theme.colorPrimaryBorder};
outline-offset: 2px;
}
`}
`;
/**
* 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, computed
* the identical way: `availableDropSpan` resolves a cell under the cursor
* (via `cellAtPoint`/`resolveCellGeometry`) against an empty `packed` map —
* the same one `RootGrid` would resolve against once it exists — and this
* positions/sizes itself from that cell with the same `pixelRectForCell`
* `RootGrid`'s own ghost uses. A cursor-following, capped-size preview
* (`FALLBACK_COL_SPAN`/`FALLBACK_ROW_SPAN`, the same cap `availableDropSpan`
* itself now applies to any open-space drop) rather than a full-placeholder
* or fixed-centered box: a block dropped near the left edge belongs at the
* left edge, and one dropped low belongs low, exactly as it would on a grid
* that already has something on it — a blank canvas earns no exception to
* that just because there is nothing yet to be beside.
*/
const DropPreview = styled.div`
${({ theme }) => css`
position: absolute;
pointer-events: none;
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
* building-block schema/renderer/platform-API work can be iterated on with a
* real natural-language chat loop rather than a mock. Does not persist
* anything yet — layout/style state lives only in memory for this demo.
*
* The chat panel itself isn't forced open here — it behaves exactly as it
* does everywhere else in the app (whatever display mode/open state the
* user already has), via the same global ChatPanelHost/ChatFloatingHost
* mounted in App.tsx.
*
* Rendering the tree itself is entirely delegated to `BuildingBlockView` —
* this page owns only its own chrome (the empty state) and knows nothing
* about node types, built-in or extension-contributed alike. There's no
* page-level title chrome: a title is just a `markdown` building block like
* any other, placed at the top of the canvas the same way the rest of the
* dashboard's content is.
*/
export default function DashboardBuilderV2() {
// Ticks on every dashboard.* mutation so this tree re-renders to reflect
// whatever the chat agent (or any other caller of the dashboard API) did.
useDashboardRevision();
const theme = useTheme();
const root = dashboard.getRoot();
const isEmpty = !root.children || root.children.length === 0;
// No `layout` to read yet — a blank root has never had one set — so this
// resolves to the exact same defaults `RootGrid` itself falls back to,
// which is what makes `DropPreview`'s own size below provably the same
// size the first real block will open at, not a separately-tuned guess.
const emptyCanvasMetrics = resolveGridMetrics(undefined, theme);
// A counter, not a plain boolean: 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" (see `RootGrid`'s own identical `dragOverCountRef`).
const [, setDragOverCount] = useState(0);
// Where the preview actually is, not just whether one is showing — the
// cell `availableDropSpan` resolved under the cursor, in the same 0-based
// `{x, y, w, h}` convention `RootGrid`'s own `ghostRect` uses. `null`
// means nothing to draw, exactly like a plain boolean would, but a real
// value also carries *where*, which a boolean never could.
const [ghostRect, setGhostRect] = useState<PackedRect | null>(null);
const placeholderRef = useRef<HTMLDivElement | null>(null);
// `dragleave`/`drop` alone are not a complete story: releasing the pointer
// somewhere that never became a drop target at all (past the browser
// window's own edge, over a panel that isn't one, or cancelling the drag
// with `Escape`) fires neither one here — the browser fires `dragend` on
// the *drag source* instead (`Palette.tsx`'s own item), which bubbles to
// `document` regardless of where the pointer ended up. Without this, the
// counter above can only ever go up during such a drag and never come
// back down, leaving `DropPreview` on screen until some unrelated later
// drag happens to rebalance it back to zero.
useEffect(() => {
const handleDragEnd = () => {
setDragOverCount(0);
setGhostRect(null);
};
document.addEventListener('dragend', handleDragEnd);
return () => document.removeEventListener('dragend', handleDragEnd);
}, []);
/**
* Where a palette drag over the empty canvas actually lands — the
* counterpart to `RootGrid`'s own `availableDropSpan` call, against an
* empty `packed` map since there is nothing here yet to be beside or
* bounded by. The live preview (`onDragOver`, below) and the actual drop
* both call this with the same inputs, so what an author sees while
* hovering is provably what they get on release, the same guarantee
* `RootGrid`'s own ghost/drop pair makes.
*/
const resolveEmptyCanvasDropRect = (
event: ReactDragEvent<HTMLElement>,
): PackedRect => {
const containerRect = event.currentTarget.getBoundingClientRect();
const cellGeometry = resolveCellGeometry(
emptyCanvasMetrics,
containerRect.width,
);
const { col, row } = cellAtPoint(
event.clientX - containerRect.left,
event.clientY - containerRect.top,
cellGeometry,
);
const cursorCol = Math.min(
emptyCanvasMetrics.columns - 1,
Math.max(0, Math.floor(col)),
);
const cursorRow = Math.max(0, Math.floor(row));
return availableDropSpan(
{},
emptyCanvasMetrics.columns,
cursorCol,
cursorRow,
FALLBACK_ROW_SPAN,
FALLBACK_COL_SPAN,
);
};
/**
* Places a block from the palette.
*
* Into whatever is selected when that can hold children, and into the root
* otherwise. An author who has just selected a section and reaches for a
* chart means to put it in that section; one who has selected a chart means
* to put the next thing beside it, not inside it.
*
* A drag from the palette says where for itself — the container it was
* dropped on takes it — so only the click needs a target chosen for it.
* Both then go through the same `placeBlock`, because two copies of what a
* freshly placed block looks like is how the two paths quietly diverge.
*/
const addBlock = (type: string): void => {
const selected = provider.getSelection();
const selectedNode =
selected === undefined ? undefined : provider.getNode(selected);
placeBlock(
selectedNode?.children !== undefined ? selectedNode.id : root.id,
type,
);
};
return (
<PageContainer vertical>
<DashboardHeader />
<Workspace>
<EditorPanel onAdd={addBlock} />
<Canvas
data-test="canvas"
onClick={event => {
// A click that reached the canvas itself passed every block on
// the way, so it is the one gesture that unambiguously means
// "nothing". A click on a block stops before here.
if (event.target === event.currentTarget) {
provider.setSelection(undefined);
}
}}
>
{isEmpty ? (
<EmptyCanvasWrapper>
{/* The dashboard itself, standing in for a canvas that has
nothing on it yet. It selects the root because that is the
only thing there is to select here, and because how the
canvas is arranged is asked in the root's properties — a
blank dashboard is exactly when that is asked, since
whatever is placed next lands in the mode already chosen.
Without this the mode would be unreachable until something
had already been placed and then rearranged. */}
<CanvasPlaceholder
ref={placeholderRef}
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
aria-label={t('Dashboard')}
data-test="empty-canvas"
onClick={() => provider.setSelection(root.id)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
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 => {
const next = Math.max(0, count - 1);
if (next === 0) setGhostRect(null);
return next;
});
}
}}
onDragOver={event => {
if (!event.dataTransfer.types.includes(PALETTE_MIME)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setGhostRect(resolveEmptyCanvasDropRect(event));
}}
onDrop={event => {
const type = event.dataTransfer.getData(PALETTE_MIME);
setDragOverCount(0);
if (type !== '') {
event.preventDefault();
const rect = resolveEmptyCanvasDropRect(event);
placeBlockAt(root.id, type, 0, {
col: rect.x + 1,
row: rect.y + 1,
colSpan: rect.w,
rowSpan: rect.h,
});
}
setGhostRect(null);
}}
>
{ghostRect ? (
(() => {
const containerWidthPx =
placeholderRef.current?.getBoundingClientRect().width ??
0;
const cellGeometry = resolveCellGeometry(
emptyCanvasMetrics,
containerWidthPx,
);
const pixelRect = pixelRectForCell(ghostRect, cellGeometry);
return (
<DropPreview
data-test="empty-canvas-drop-preview"
style={{
left: pixelRect.left,
top: pixelRect.top,
width: pixelRect.width,
height: pixelRect.height,
}}
/>
);
})()
) : (
<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>
) : (
<BuildingBlockView nodeId={root.id} />
)}
</Canvas>
</Workspace>
</PageContainer>
);
}
@@ -0,0 +1,102 @@
/**
* 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 inferPropsSchema, { untypedKeys } from './inferPropsSchema';
test('each property is typed by the value the block is holding', () => {
const schema = inferPropsSchema({
title: 'Revenue',
limit: 10,
showLegend: true,
});
expect(schema).toEqual({
type: 'object',
properties: {
title: { type: 'string' },
limit: { type: 'number' },
showLegend: { type: 'boolean' },
},
});
});
test('a nested object is described all the way down', () => {
// `dataBinding` is the shape most worth reaching in a form rather than in
// a string of JSON, and it is two levels deep before it says anything.
const schema = inferPropsSchema({
dataBinding: { datasetId: 3, filters: { region: 'EMEA' } },
});
expect(schema.properties?.dataBinding).toEqual({
type: 'object',
properties: {
datasetId: { type: 'number' },
filters: {
type: 'object',
properties: { region: { type: 'string' } },
},
},
});
});
test('a list is described by what is in it', () => {
const schema = inferPropsSchema({
metrics: ['count', 'sum__value'],
columnDefs: [{ field: 'name', width: 120 }],
});
expect(schema.properties?.metrics).toEqual({
type: 'array',
items: { type: 'string' },
});
expect(schema.properties?.columnDefs).toEqual({
type: 'array',
items: {
type: 'object',
properties: { field: { type: 'string' }, width: { type: 'number' } },
},
});
});
test('an empty list is still a list, of nothing in particular', () => {
// There is no element to read a type off, and guessing one would make the
// first thing added to it the wrong type.
const schema = inferPropsSchema({ metrics: [] });
expect(schema.properties?.metrics).toEqual({ type: 'array', items: {} });
});
test('a property holding nothing is left out rather than given a type it has not got', () => {
// `null` says only that the key exists. Typing it as a string would turn
// the first edit into a silent change of type, and typing it as an object
// would render a group with no fields — so the form declines it and says
// where it can still be edited.
const schema = inferPropsSchema({ kept: 'yes', cleared: null });
expect(Object.keys(schema.properties ?? {})).toEqual(['kept']);
expect(untypedKeys({ kept: 'yes', cleared: null })).toEqual(['cleared']);
});
test('a block with no properties has an empty schema rather than no schema', () => {
// JsonForms is handed this either way; an absent `properties` throws where
// an empty one renders nothing.
expect(inferPropsSchema(undefined)).toEqual({
type: 'object',
properties: {},
});
});
@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { JsonSchema7 } from '@jsonforms/core';
/**
* One value's type, or `undefined` where the value does not carry one.
*
* `null` is the case that matters: it says a key exists and nothing else.
* Calling it a string would make the first edit a silent change of type, and
* calling it an object would render a group with no fields in it.
*/
function describe(value: unknown): JsonSchema7 | undefined {
if (typeof value === 'string') return { type: 'string' };
if (typeof value === 'boolean') return { type: 'boolean' };
if (typeof value === 'number' && Number.isFinite(value)) {
return { type: 'number' };
}
if (Array.isArray(value)) {
// Typed by its first element, which is the only element there is to read.
// A list holding more than one shape renders as the first one — a JSON
// question that the JSON half of the panel is the place to answer.
return { type: 'array', items: describe(value[0]) ?? {} };
}
if (typeof value === 'object' && value !== null) {
const properties: Record<string, JsonSchema7> = {};
for (const [key, held] of Object.entries(value)) {
const described = describe(held);
if (described !== undefined) {
properties[key] = described;
}
}
return { type: 'object', properties };
}
return undefined;
}
/**
* A block's properties, described as a JSON Schema so they can be edited in a
* form instead of in a string of JSON.
*
* Read off the values rather than declared per block type, and deliberately
* so: `BuildingBlockView` resolves a renderer through a registry an extension
* writes into, and a schema per type would make this panel the one place that
* has to learn every type there is — the exact knowledge the render path is
* built not to have. A schema shipped alongside each registration would be
* better still, and this is what stands in until there is one: it describes
* whatever the block is holding, built-in or contributed, with no list to
* keep current.
*
* What it cannot do is invent a key that is not there. A property nothing has
* written yet has no value to read a type off, so it does not appear — which
* is the JSON editor's half of the same panel: that one edits the shape, this
* one edits the values in it.
*/
export default function inferPropsSchema(
props: Record<string, unknown> | undefined,
): JsonSchema7 {
// A record is always an object, so this branch of `describe` always answers.
return describe(props ?? {}) as JsonSchema7;
}
/**
* The keys `inferPropsSchema` declined, so the form can say what it is not
* showing rather than quietly dropping it.
*/
export function untypedKeys(
props: Record<string, unknown> | undefined,
): string[] {
return Object.entries(props ?? {})
.filter(([, value]) => describe(value) === undefined)
.map(([key]) => key);
}
@@ -27,6 +27,7 @@ export const RoutePaths = {
FILE_HANDLER: '/file-handler',
DASHBOARD: '/dashboard/:idOrSlug/',
DASHBOARD_LIST: '/dashboard/list/',
DASHBOARD_V2_NEW: '/dashboard/v2/new/',
CHART_ADD: '/chart/add',
CHART_LIST: '/chart/list/',
DATASET_LIST: '/tablemodelview/list/',
+10
View File
@@ -80,6 +80,13 @@ const Dashboard = lazy(
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
);
const DashboardBuilderV2 = lazy(
() =>
import(
/* webpackChunkName: "DashboardBuilderV2" */ 'src/pages/DashboardBuilderV2'
),
);
const DatabaseList = lazy(
() => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
);
@@ -205,6 +212,9 @@ export const routes: Routes = [
{ path: RoutePaths.HOME, Component: Home },
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
// Must precede DASHBOARD ("/dashboard/:idOrSlug/") — that pattern is a
// non-exact prefix match, so it would otherwise shadow this literal path.
{ path: RoutePaths.DASHBOARD_V2_NEW, Component: DashboardBuilderV2 },
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
{ path: RoutePaths.CHART_LIST, Component: ChartList },
+2
View File
@@ -206,6 +206,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.css_templates import CssTemplateModelView
from superset.views.dashboard.views import (
Dashboard,
DashboardBuilderV2View,
DashboardModelView,
)
from superset.views.database.views import DatabaseView
@@ -459,6 +460,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
#
appbuilder.add_view_no_menu(Api)
appbuilder.add_view_no_menu(Dashboard)
appbuilder.add_view_no_menu(DashboardBuilderV2View)
appbuilder.add_view_no_menu(Datasource)
appbuilder.add_view_no_menu(DatasetEditor)
appbuilder.add_view_no_menu(EmbeddedView)
+19
View File
@@ -131,3 +131,22 @@ class Dashboard(BaseSupersetView):
return self.render_app_template(
extra_bootstrap_data=bootstrap_data, entry="embedded"
)
class DashboardBuilderV2View(BaseSupersetView):
"""Serves the Dashboard v2 prototype builder page (design-doc item 7.1).
A pure React-Router route with no server-side state of its own — this
view exists only so a full page load (refresh, direct URL, bookmark)
matches a Flask route and gets the SPA shell instead of a 404; the
actual page is rendered entirely client-side.
"""
route_base = "/dashboard/v2"
class_permission_name = "Dashboard"
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
@has_access
@expose("/new/")
def new(self) -> FlaskResponse:
return super().render_app_template()