mirror of
https://github.com/apache/superset.git
synced 2026-08-17 05:21:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368aa3df87 | ||
|
|
46be6a032d | ||
|
|
116142e57f | ||
|
|
ab59385427 |
@@ -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: [
|
||||
|
||||
Generated
+17
-24
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+75
@@ -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');
|
||||
});
|
||||
+163
@@ -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 item’s 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 item’s 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 grid’s 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 };
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user