From 016f9c8f2adb865fea3744f6eb0e0b56cafe2de6 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 5 Aug 2026 11:36:42 +0200 Subject: [PATCH 01/19] feat: adds client tools --- .../packages/superset-core/src/chat/index.ts | 57 ++- .../superset-core/src/navigation/index.ts | 5 +- .../src/core/chat/ChatProvider.test.ts | 104 ++++ .../src/core/chat/ChatProvider.ts | 71 +++ superset-frontend/src/core/chat/index.test.ts | 21 + superset-frontend/src/core/chat/index.ts | 4 + .../src/core/navigation/index.test.ts | 6 + .../src/core/navigation/index.ts | 1 + .../DashboardBuilderV2/clientTools.test.ts | 145 ++++++ .../pages/DashboardBuilderV2/clientTools.ts | 443 ++++++++++++++++++ .../src/pages/DashboardBuilderV2/index.tsx | 7 + 11 files changed, 859 insertions(+), 5 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/clientTools.test.ts create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/clientTools.ts diff --git a/superset-frontend/packages/superset-core/src/chat/index.ts b/superset-frontend/packages/superset-core/src/chat/index.ts index ad91761f150d..1fb689361e06 100644 --- a/superset-frontend/packages/superset-core/src/chat/index.ts +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -151,6 +151,57 @@ export declare const onDidChangeDisplayMode: Event; */ export declare const onDidResizePanel: Event<{ width: number }>; -// TODO: client actions API — tool availability functions will be added here -// once the client_actions SIP is finalized. The chat namespace is the -// intended integration point between the two SIPs. +/** The normalized answer returned to the assistant by a browser-owned tool. */ +export interface ClientToolResult { + content: string; + isError?: boolean; +} + +/** The part of a browser-owned tool definition that is safe to send to a model. */ +export interface ClientToolSpec { + /** Unique tool name. Later registrations take precedence on collisions. */ + name: string; + /** Explain when the model should use the tool and what it returns. */ + description: string; + /** JSON Schema describing the tool's argument object. */ + inputSchema: Record; +} + +/** + * A tool implemented by the page in the user's browser. + * + * The handler is never sent to the chat extension's backend. It executes in + * the active Superset page and should return concise, model-readable content. + */ +export interface ClientTool extends ClientToolSpec { + execute: ( + args: Record, + ) => Promise | ClientToolResult; +} + +/** + * Registers browser-owned tools for the lifetime of a page or component. + * Dispose the returned registration when that owner unmounts. If names + * collide, the most recently registered group wins until it is disposed. + */ +export declare function registerClientTools( + tools: readonly ClientTool[], +): Disposable; + +/** Returns the available client-tool descriptions without their handlers. */ +export declare function getClientTools(): ClientToolSpec[]; + +/** + * Executes a registered browser-owned tool. + * + * This promise always resolves. Unknown tools, thrown handlers, rejected + * handlers, and invalid results are normalized to `isError: true` so an + * assistant can always continue its tool-call loop. + */ +export declare function executeClientTool( + name: string, + args: Record, +): Promise; + +/** Fires whenever the resolved set of browser-owned tools changes. */ +export declare const onDidChangeClientTools: Event; diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts index 585a87f2e9fc..bf92ee179465 100644 --- a/superset-frontend/packages/superset-core/src/navigation/index.ts +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -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' diff --git a/superset-frontend/src/core/chat/ChatProvider.test.ts b/superset-frontend/src/core/chat/ChatProvider.test.ts index 392f50a114f8..accc2986b652 100644 --- a/superset-frontend/src/core/chat/ChatProvider.test.ts +++ b/superset-frontend/src/core/chat/ChatProvider.test.ts @@ -230,6 +230,101 @@ test('setDisplayMode updates mode and fires event only on change', () => { expect(modeChanged).toHaveBeenCalledWith('panel'); }); +test('client tools are exposed without their browser handlers', () => { + const provider = ChatProvider.getInstance(); + const execute = jest.fn(() => ({ content: 'outline' })); + + provider.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Reads the active dashboard.', + inputSchema: { type: 'object', properties: {} }, + execute, + }, + ]); + + expect(provider.getClientTools()).toEqual([ + { + name: 'dashboard_get_state', + description: 'Reads the active dashboard.', + inputSchema: { type: 'object', properties: {} }, + }, + ]); +}); + +test('the latest client-tool registration wins until it is disposed', async () => { + const provider = ChatProvider.getInstance(); + provider.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Earlier tool.', + inputSchema: { type: 'object' }, + execute: () => ({ content: 'earlier' }), + }, + ]); + const latest = provider.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Latest tool.', + inputSchema: { type: 'object' }, + execute: () => ({ content: 'latest' }), + }, + ]); + + await expect( + provider.executeClientTool('dashboard_get_state', {}), + ).resolves.toEqual({ content: 'latest' }); + + latest.dispose(); + await expect( + provider.executeClientTool('dashboard_get_state', {}), + ).resolves.toEqual({ content: 'earlier' }); +}); + +test('client-tool changes fire after registration and disposal', () => { + const provider = ChatProvider.getInstance(); + const listener = jest.fn(); + provider.onDidChangeClientTools(listener); + const registration = provider.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Reads the active dashboard.', + inputSchema: { type: 'object' }, + execute: () => ({ content: 'outline' }), + }, + ]); + + expect(listener).toHaveBeenLastCalledWith([ + expect.objectContaining({ name: 'dashboard_get_state' }), + ]); + + registration.dispose(); + expect(listener).toHaveBeenLastCalledWith([]); +}); + +test('client-tool execution always resolves with a model-readable result', async () => { + const provider = ChatProvider.getInstance(); + provider.registerClientTools([ + { + name: 'throws', + description: 'Throws.', + inputSchema: { type: 'object' }, + execute: () => { + throw new Error('bad arguments'); + }, + }, + ]); + + await expect(provider.executeClientTool('missing', {})).resolves.toEqual({ + content: 'Unknown client tool "missing".', + isError: true, + }); + await expect(provider.executeClientTool('throws', {})).resolves.toEqual({ + content: 'Client tool "throws" failed: bad arguments', + isError: true, + }); +}); + test('state reflects changes after registration and open', () => { const provider = ChatProvider.getInstance(); @@ -248,10 +343,19 @@ test('reset clears all state', () => { provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); provider.open(); provider.setDisplayMode('panel'); + provider.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Reads the active dashboard.', + inputSchema: { type: 'object' }, + execute: () => ({ content: 'outline' }), + }, + ]); provider.reset(); expect(provider.getChat()).toBeUndefined(); expect(provider.isOpen()).toBe(false); expect(provider.getDisplayMode()).toBe('floating'); + expect(provider.getClientTools()).toEqual([]); }); diff --git a/superset-frontend/src/core/chat/ChatProvider.ts b/superset-frontend/src/core/chat/ChatProvider.ts index b0d2f79af50e..9e2b55e64a42 100644 --- a/superset-frontend/src/core/chat/ChatProvider.ts +++ b/superset-frontend/src/core/chat/ChatProvider.ts @@ -29,6 +29,9 @@ import { createValueEventEmitter, createEventEmitter } from '../utils'; type Chat = chatApi.Chat; type DisplayMode = chatApi.DisplayMode; +type ClientTool = chatApi.ClientTool; +type ClientToolResult = chatApi.ClientToolResult; +type ClientToolSpec = chatApi.ClientToolSpec; /** * Singleton manager for the chat provider. @@ -57,6 +60,10 @@ class ChatProvider { private resizePanelEmitter = createEventEmitter<{ width: number }>(); + private clientToolGroups: ClientTool[][] = []; + + private clientToolsEmitter = createEventEmitter(); + private modeEmitter: ReturnType>; private constructor() { @@ -190,6 +197,68 @@ class ChatProvider { return this.resizePanelEmitter.subscribe; } + private resolveClientTools(): Map { + const resolved = new Map(); + this.clientToolGroups.forEach(group => { + group.forEach(tool => resolved.set(tool.name, tool)); + }); + return resolved; + } + + public registerClientTools(tools: readonly ClientTool[]): Disposable { + const group = [...tools]; + this.clientToolGroups.push(group); + this.clientToolsEmitter.fire(this.getClientTools()); + + return new Disposable(() => { + const index = this.clientToolGroups.indexOf(group); + if (index === -1) return; + this.clientToolGroups.splice(index, 1); + this.clientToolsEmitter.fire(this.getClientTools()); + }); + } + + public getClientTools(): ClientToolSpec[] { + return [...this.resolveClientTools().values()].map( + ({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + }), + ); + } + + public async executeClientTool( + name: string, + args: Record, + ): Promise { + const tool = this.resolveClientTools().get(name); + if (!tool) { + return { content: `Unknown client tool "${name}".`, isError: true }; + } + + try { + const result = await tool.execute(args); + if (result && typeof result.content === 'string') return result; + return { + content: `Client tool "${name}" returned no content.`, + isError: true, + }; + } catch (error) { + return { + content: + error instanceof Error + ? `Client tool "${name}" failed: ${error.message}` + : `Client tool "${name}" failed.`, + isError: true, + }; + } + } + + public get onDidChangeClientTools() { + return this.clientToolsEmitter.subscribe; + } + public reset(): void { this.chat = undefined; this.trigger = undefined; @@ -200,6 +269,8 @@ class ChatProvider { this.openEmitter = createEventEmitter(); this.closeEmitter = createEventEmitter(); this.resizePanelEmitter = createEventEmitter<{ width: number }>(); + this.clientToolGroups = []; + this.clientToolsEmitter = createEventEmitter(); this.modeEmitter = createValueEventEmitter('floating'); this.stateSubscribers.clear(); setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' }); diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts index 2fee1989135f..10359cbc6054 100644 --- a/superset-frontend/src/core/chat/index.test.ts +++ b/superset-frontend/src/core/chat/index.test.ts @@ -66,3 +66,24 @@ test('setDisplayMode updates the display mode', () => { chat.setDisplayMode('panel'); expect(chat.getDisplayMode()).toBe('panel'); }); + +test('registerClientTools exposes and executes a page-owned tool', async () => { + const registration = chat.registerClientTools([ + { + name: 'dashboard_get_state', + description: 'Reads the active dashboard.', + inputSchema: { type: 'object', properties: {} }, + execute: args => ({ content: JSON.stringify(args) }), + }, + ]); + + expect(chat.getClientTools()).toEqual([ + expect.objectContaining({ name: 'dashboard_get_state' }), + ]); + await expect( + chat.executeClientTool('dashboard_get_state', { detail: 'full' }), + ).resolves.toEqual({ content: '{"detail":"full"}' }); + + registration.dispose(); + expect(chat.getClientTools()).toEqual([]); +}); diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts index b7536760926f..de885e7316fd 100644 --- a/superset-frontend/src/core/chat/index.ts +++ b/superset-frontend/src/core/chat/index.ts @@ -79,4 +79,8 @@ export const chat: typeof chatApi = { // The host fires this from its panel resizer; until that chrome exists the // event is exposed but never fires. onDidResizePanel: provider.onDidResizePanel, + registerClientTools: provider.registerClientTools.bind(provider), + getClientTools: provider.getClientTools.bind(provider), + executeClientTool: provider.executeClientTool.bind(provider), + onDidChangeClientTools: provider.onDidChangeClientTools, }; diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts index c717d3779ef1..0e6f6eaff8c2 100644 --- a/superset-frontend/src/core/navigation/index.test.ts +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -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/'); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts index 2e2a61d3fb4d..0fbe276a5b09 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -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' }, diff --git a/superset-frontend/src/pages/DashboardBuilderV2/clientTools.test.ts b/superset-frontend/src/pages/DashboardBuilderV2/clientTools.test.ts new file mode 100644 index 000000000000..f0468b7ddc36 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/clientTools.test.ts @@ -0,0 +1,145 @@ +/** + * 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 { chat } from 'src/core/chat'; +import ChatProvider from 'src/core/chat/ChatProvider'; +import DashboardProvider from 'src/core/dashboard/DashboardProvider'; +import { dashboardClientTools } from './clientTools'; + +beforeEach(() => { + ChatProvider.getInstance().reset(); + DashboardProvider.getInstance().reset(); + chat.registerClientTools(dashboardClientTools); +}); + +async function execute(name: string, args: Record) { + const result = await chat.executeClientTool(name, args); + return { ...result, value: JSON.parse(result.content) }; +} + +test('reads the blank Dashboard v2 tree', async () => { + const result = await execute('dashboard_get_state', {}); + + expect(result.isError).toBeUndefined(); + expect(result.value).toEqual({ + rootId: 'root', + nodes: { + root: { + id: 'root', + type: 'canvas', + layout: { columns: 24, gap: 16 }, + children: [], + }, + }, + }); +}); + +test('adds and edits a visible dashboard block', async () => { + const added = await execute('dashboard_add_building_block', { + parent_id: 'root', + block: { + type: 'markdown', + layout: { colSpan: 12, rowSpan: 2 }, + props: { content: '# Revenue' }, + }, + }); + const id = added.value.node.id as string; + + await execute('dashboard_update_layout', { + id, + layout: { col: 3, row: 2, colSpan: 10 }, + }); + const updated = await execute('dashboard_update_props', { + id, + props: { content: '# Net revenue' }, + }); + + expect(updated.value.node).toEqual( + expect.objectContaining({ + id, + layout: expect.objectContaining({ col: 3, row: 2, colSpan: 10 }), + props: { content: '# Net revenue' }, + }), + ); +}); + +test('changes an ECharts palette without replacing the existing chart options', async () => { + const provider = DashboardProvider.getInstance(); + const id = provider.addBuildingBlock('root', 0, { + type: 'echarts', + props: { + dataBinding: { datasetId: 1, metrics: ['sum__sales'] }, + echartsOptions: { + color: ['#old'], + xAxis: { type: 'category' }, + series: [{ type: 'bar', data: [1, 2] }], + }, + }, + }); + + const updated = await execute('dashboard_update_props', { + id, + props: { echartsOptions: { color: ['#1677ff', '#52c41a'] } }, + }); + + expect(updated.isError).toBeUndefined(); + expect(updated.value.node.props.echartsOptions).toEqual({ + color: ['#1677ff', '#52c41a'], + xAxis: { type: 'category' }, + series: [{ type: 'bar', data: [1, 2] }], + }); +}); + +test('moves a block into a nested canvas and removes the subtree', async () => { + const canvas = await execute('dashboard_add_building_block', { + parent_id: 'root', + block: { type: 'canvas', layout: { columns: 12, colSpan: 24 } }, + }); + const canvasId = canvas.value.node.id as string; + const markdown = await execute('dashboard_add_building_block', { + parent_id: 'root', + block: { type: 'markdown', props: { content: 'Move me' } }, + }); + const markdownId = markdown.value.node.id as string; + + await execute('dashboard_move_building_block', { + id: markdownId, + new_parent_id: canvasId, + new_index: 0, + }); + let state = await execute('dashboard_get_state', {}); + expect(state.value.nodes[canvasId].children).toEqual([markdownId]); + + await execute('dashboard_remove_building_block', { id: canvasId }); + state = await execute('dashboard_get_state', {}); + expect(state.value.nodes[canvasId]).toBeUndefined(); + expect(state.value.nodes[markdownId]).toBeUndefined(); +}); + +test('invalid model arguments become a client-tool error', async () => { + const result = await chat.executeClientTool('dashboard_add_building_block', { + parent_id: 'missing', + block: { type: 'markdown' }, + }); + + expect(result).toEqual({ + content: + 'Client tool "dashboard_add_building_block" failed: Parent "missing" is not a canvas node.', + isError: true, + }); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/clientTools.ts b/superset-frontend/src/pages/DashboardBuilderV2/clientTools.ts new file mode 100644 index 000000000000..3795cdf0f06a --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/clientTools.ts @@ -0,0 +1,443 @@ +/** + * 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 { + chat as chatApi, + dashboard as dashboardApi, +} from '@apache-superset/core'; +import { dashboard } from 'src/core/dashboard'; + +type ClientTool = chatApi.ClientTool; +type ClientToolResult = chatApi.ClientToolResult; +type DashboardNode = dashboardApi.DashboardNode; +type BuildingBlockSpec = dashboardApi.BuildingBlockSpec; +type LayoutProps = dashboardApi.LayoutProps; +type DataBindingSpec = dashboardApi.DataBindingSpec; +type QueryDataResult = dashboardApi.QueryDataResult; + +const QUERY_BACKED_BLOCKS = new Set([ + 'echarts', + 'ag-grid-table', + 'metric-tile', +]); +const PREVIEW_ROWS = 20; + +const emptyInputSchema = { + type: 'object', + properties: {}, + additionalProperties: false, +}; + +const layoutSchema = { + type: 'object', + description: + 'Grid geometry. col/row are 1-based; omit both to auto-place the block.', + properties: { + columns: { type: 'integer', minimum: 1 }, + gap: { type: 'number', minimum: 0 }, + rowUnit: { type: 'number', exclusiveMinimum: 0 }, + colSpan: { type: 'integer', minimum: 1 }, + rowSpan: { type: 'integer', minimum: 1 }, + col: { type: 'integer', minimum: 1 }, + row: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, +}; + +const dataBindingSchema = { + type: 'object', + properties: { + datasetId: { type: 'integer', minimum: 1 }, + metrics: { + type: 'array', + description: 'Saved metric names or Superset ad-hoc metric objects.', + items: {}, + }, + dimensions: { type: 'array', items: { type: 'string' } }, + filters: { type: 'array', items: { type: 'object' } }, + rowLimit: { type: 'integer', minimum: 1 }, + }, + required: ['datasetId', 'metrics'], + additionalProperties: false, +}; + +function jsonResult(value: unknown): ClientToolResult { + return { content: JSON.stringify(value, null, 2) }; +} + +function asRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as Record; +} + +function requiredRecord( + args: Record, + key: string, +): Record { + return asRecord(args[key], `"${key}"`); +} + +function requiredString(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`"${key}" must be a non-empty string.`); + } + return value; +} + +function requiredInteger(args: Record, key: string): number { + const value = args[key]; + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`"${key}" must be an integer.`); + } + return value; +} + +function optionalInteger( + args: Record, + key: string, + fallback: number, +): number { + return args[key] === undefined ? fallback : requiredInteger(args, key); +} + +function positiveNumber( + value: unknown, + label: string, + integer: boolean, + allowZero = false, +): number { + const lowerBoundValid = + typeof value === 'number' && (allowZero ? value >= 0 : value > 0); + if (!lowerBoundValid || (integer && !Number.isInteger(value))) { + const kind = integer ? 'integer' : 'number'; + throw new Error( + `${label} must be a ${allowZero ? 'non-negative' : 'positive'} ${kind}.`, + ); + } + return value; +} + +function optionalLayout(value: unknown): Partial | undefined { + if (value === undefined) return undefined; + const layout = asRecord(value, '"layout"'); + const result: Partial = {}; + if (layout.columns !== undefined) { + result.columns = positiveNumber(layout.columns, 'layout.columns', true); + } + if (layout.gap !== undefined) { + result.gap = positiveNumber(layout.gap, 'layout.gap', false, true); + } + if (layout.rowUnit !== undefined) { + result.rowUnit = positiveNumber(layout.rowUnit, 'layout.rowUnit', false); + } + if (layout.colSpan !== undefined) { + result.colSpan = positiveNumber(layout.colSpan, 'layout.colSpan', true); + } + if (layout.rowSpan !== undefined) { + result.rowSpan = positiveNumber(layout.rowSpan, 'layout.rowSpan', true); + } + if (layout.col !== undefined) { + result.col = positiveNumber(layout.col, 'layout.col', true); + } + if (layout.row !== undefined) { + result.row = positiveNumber(layout.row, 'layout.row', true); + } + return result; +} + +function dataBinding(value: unknown): DataBindingSpec { + const binding = asRecord(value, '"dataBinding"'); + const datasetId = positiveNumber( + binding.datasetId, + 'dataBinding.datasetId', + true, + ); + if (!Array.isArray(binding.metrics)) { + throw new Error('dataBinding.metrics must be an array.'); + } + if ( + binding.dimensions !== undefined && + (!Array.isArray(binding.dimensions) || + !binding.dimensions.every(item => typeof item === 'string')) + ) { + throw new Error('dataBinding.dimensions must be an array of strings.'); + } + if ( + binding.filters !== undefined && + (!Array.isArray(binding.filters) || + !binding.filters.every( + item => + item !== null && typeof item === 'object' && !Array.isArray(item), + )) + ) { + throw new Error('dataBinding.filters must be an array of objects.'); + } + + const rowLimit = + binding.rowLimit === undefined + ? undefined + : positiveNumber(binding.rowLimit, 'dataBinding.rowLimit', true); + + return { + datasetId, + metrics: binding.metrics, + dimensions: binding.dimensions as string[] | undefined, + filters: binding.filters as Record[] | undefined, + rowLimit, + }; +} + +function blockSpec(value: unknown): BuildingBlockSpec { + const block = asRecord(value, '"block"'); + if (typeof block.type !== 'string' || block.type.trim() === '') { + throw new Error('block.type must be a non-empty string.'); + } + return { + type: block.type, + layout: optionalLayout(block.layout), + props: + block.props === undefined + ? undefined + : asRecord(block.props, 'block.props'), + style: + block.style === undefined + ? undefined + : asRecord(block.style, 'block.style'), + }; +} + +async function validateQueryBackedBlock( + type: string, + props: Record | undefined, + requireBinding: boolean, +): Promise { + if (!QUERY_BACKED_BLOCKS.has(type)) return undefined; + if (!props || props.dataBinding === undefined) { + if (requireBinding) { + throw new Error(`${type} blocks require props.dataBinding.`); + } + return undefined; + } + return dashboard.fetchQueryData(dataBinding(props.dataBinding)); +} + +function queryPreview(result: QueryDataResult | undefined) { + return result + ? { columns: result.columns, rows: result.rows.slice(0, PREVIEW_ROWS) } + : undefined; +} + +function readDashboardState() { + const root = dashboard.getRoot(); + const nodes: Record = {}; + + const visit = (node: DashboardNode) => { + nodes[node.id] = node; + node.children?.forEach(id => { + const child = dashboard.getNode(id); + if (child) visit(child); + }); + }; + visit(root); + + return { rootId: root.id, nodes }; +} + +/** + * Tools offered only while Dashboard v2 is mounted. Their handlers use the + * same public dashboard API that extensions use, so model edits and direct UI + * edits share one store, renderer, collision policy, and revision stream. + */ +export const dashboardClientTools: ClientTool[] = [ + { + name: 'dashboard_get_state', + description: + 'Read the complete unsaved Dashboard v2 tree visible on screen. Call this before editing so you use real node and parent ids. Returns {rootId,nodes}, where nodes is keyed by id and each node has type, layout, props, style, and canvas child ids. Built-in types are canvas, markdown, echarts, ag-grid-table, and metric-tile.', + inputSchema: emptyInputSchema, + execute: () => jsonResult(readDashboardState()), + }, + { + name: 'dashboard_validate_data_binding', + description: + 'Run a Dashboard v2 data binding against Superset before creating or changing a live chart, table, or metric tile. Use exact dataset, metric, and dimension names obtained from server tools. Returns result column aliases and up to 20 preview rows; use those exact aliases in ECharts $bind markers.', + inputSchema: { + type: 'object', + properties: { dataBinding: dataBindingSchema }, + required: ['dataBinding'], + additionalProperties: false, + }, + execute: async args => { + const result = await dashboard.fetchQueryData( + dataBinding(args.dataBinding), + ); + return jsonResult(queryPreview(result)); + }, + }, + { + name: 'dashboard_add_building_block', + description: + 'Add a block to the unsaved Dashboard v2 canvas and show it immediately. Read the state first. parent_id must name a canvas; omit index to append. Built-ins: markdown props {content}; metric-tile props {dataBinding,label?,prefix?,suffix?,decimals?}; ag-grid-table props {dataBinding,columnDefs?}; echarts props {dataBinding,echartsOptions}. In echartsOptions bind result data with {"$bind":{"source":"metric"|"dimension","alias":"exact column alias","single":true?}}, record arrays with {"$bind":{"source":"records","fields":{"name":"dimension alias","value":"metric alias"}}}, and theme tokens with {"$bind":{"source":"theme","token":"colorPrimary"}}. A canvas has children and may set layout.columns/gap/rowUnit. Every block layout may set colSpan/rowSpan and optional 1-based col/row. Query-backed blocks are validated before insertion. Returns the created node and a data preview when applicable.', + inputSchema: { + type: 'object', + properties: { + parent_id: { type: 'string' }, + index: { type: 'integer', minimum: 0 }, + block: { + type: 'object', + properties: { + type: { type: 'string' }, + layout: layoutSchema, + props: { type: 'object' }, + style: { type: 'object' }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + required: ['parent_id', 'block'], + additionalProperties: false, + }, + execute: async args => { + const parentId = requiredString(args, 'parent_id'); + const parent = dashboard.getNode(parentId); + if (!parent?.children) { + throw new Error(`Parent "${parentId}" is not a canvas node.`); + } + const index = optionalInteger(args, 'index', parent.children.length); + if (index < 0) throw new Error('"index" must be non-negative.'); + const spec = blockSpec(args.block); + const preview = await validateQueryBackedBlock( + spec.type, + spec.props, + true, + ); + const id = dashboard.addBuildingBlock(parentId, index, spec); + return jsonResult({ + node: dashboard.getNode(id), + preview: queryPreview(preview), + }); + }, + }, + { + name: 'dashboard_update_layout', + description: + 'Move or resize one existing Dashboard v2 node within its current parent grid. col/row are 1-based; colSpan/rowSpan control size. Explicit collisions push later blocks downward. Returns the updated node.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + layout: layoutSchema, + }, + required: ['id', 'layout'], + additionalProperties: false, + }, + execute: args => { + const id = requiredString(args, 'id'); + const layout = optionalLayout(args.layout); + if (!layout) throw new Error('"layout" is required.'); + dashboard.updateLayout(id, layout); + return jsonResult({ node: dashboard.getNode(id) }); + }, + }, + { + name: 'dashboard_update_props', + description: + 'Merge content properties into an existing Dashboard v2 block and show the change immediately. Read dashboard_get_state first and use its node id; no saved dashboard or chart id is needed. Use this for markdown content, ECharts options/dataBinding, table configuration, or metric-tile labels and formatting. echartsOptions is merged at its top level, so a color-only change can send props {echartsOptions: {color: ["#hex", ...]}} without replacing axes, series, or data bindings. For multiple charts, call this separately for each chart node. A changed dataBinding is validated before the update. Returns the updated node and a data preview when applicable.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + props: { type: 'object' }, + }, + required: ['id', 'props'], + additionalProperties: false, + }, + execute: async args => { + const id = requiredString(args, 'id'); + const node = dashboard.getNode(id); + if (!node) throw new Error(`Unknown dashboard node "${id}".`); + const props = requiredRecord(args, 'props'); + const nextProps = { ...props }; + if (props.echartsOptions !== undefined) { + const currentOptions = + node.props?.echartsOptions === undefined + ? {} + : asRecord(node.props.echartsOptions, 'existing echartsOptions'); + nextProps.echartsOptions = { + ...currentOptions, + ...asRecord(props.echartsOptions, 'props.echartsOptions'), + }; + } + const mergedProps = { ...node.props, ...nextProps }; + const preview = + props.dataBinding === undefined + ? undefined + : await validateQueryBackedBlock(node.type, mergedProps, false); + dashboard.updateProps(id, nextProps); + return jsonResult({ + node: dashboard.getNode(id), + preview: queryPreview(preview), + }); + }, + }, + { + name: 'dashboard_move_building_block', + description: + 'Move an existing Dashboard v2 block (including a canvas subtree) into another canvas. The old explicit grid position is cleared so it auto-places in the destination. Returns the moved node and destination canvas.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + new_parent_id: { type: 'string' }, + new_index: { type: 'integer', minimum: 0 }, + }, + required: ['id', 'new_parent_id', 'new_index'], + additionalProperties: false, + }, + execute: args => { + const id = requiredString(args, 'id'); + const newParentId = requiredString(args, 'new_parent_id'); + const newIndex = requiredInteger(args, 'new_index'); + if (newIndex < 0) throw new Error('"new_index" must be non-negative.'); + dashboard.moveBuildingBlock(id, newParentId, newIndex); + return jsonResult({ + node: dashboard.getNode(id), + destination: dashboard.getNode(newParentId), + }); + }, + }, + { + name: 'dashboard_remove_building_block', + description: + 'Remove one Dashboard v2 block from the unsaved canvas. Removing a canvas also removes its entire subtree. Never remove the root. Read the state first and use the exact node id. Returns the removed id and the resulting dashboard state.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + execute: args => { + const id = requiredString(args, 'id'); + dashboard.removeBuildingBlock(id); + return jsonResult({ removed: id, dashboard: readDashboardState() }); + }, + }, +]; diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 287ea64b7cba..efc1db8d7011 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -16,12 +16,15 @@ * specific language governing permissions and limitations * under the License. */ +import { useEffect } from 'react'; import { t } from '@apache-superset/core/translation'; import { css, styled } from '@apache-superset/core/theme'; import { Flex, Typography } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { dashboard, useDashboardRevision } from 'src/core/dashboard'; +import { chat } from 'src/core/chat'; import BuildingBlockView from 'src/core/dashboard/BuildingBlockView'; +import { dashboardClientTools } from './clientTools'; const PageContainer = styled(Flex)` ${({ theme }) => css` @@ -84,6 +87,10 @@ 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(); + useEffect(() => { + const registration = chat.registerClientTools(dashboardClientTools); + return () => registration.dispose(); + }, []); const root = dashboard.getRoot(); const isEmpty = !root.children || root.children.length === 0; From 4669e953b067ec3ceafe128dea11fe85ad46b09d Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Thu, 6 Aug 2026 01:06:42 +0200 Subject: [PATCH 02/19] feat(dashboard-v2): let a container say how it arranges its children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three arrangements, one of which is what every dashboard already does. `grid` is the default and means exactly what a container meant before this field existed, so every stored node and every AI tool call that omits `mode` behaves identically. The other two are the ones that could not be expressed as a grid. `free` because compaction belongs to the container rather than to any child's coordinates: it reads the same four child fields `grid` does, so moving between them never discards a position an author or an agent set. `flex` because a proportional line has no cells to name — position there is order in `children`, which is why the gesture that arranges one is a reorder committing through the same `moveBuildingBlock` the tools call. Without that, flex would be a mode you can see and cannot author in. A flow is still not a mode, and the comment saying so is kept. So is the one recording that `compactType={null}` displaces siblings without bound — `free` pairs it with `allowOverlap`, which removes the collision resolution rather than leaving it running with nothing to settle it, so a free canvas never enters the path that was found to fail. The switcher edits `layout.mode` through `updateLayout`, the same call an agent makes, so asking for a free canvas and pressing Free are one edit and the control shows whichever happened last. Co-Authored-By: Claude Opus 5 --- .../superset-core/src/dashboard/index.ts | 55 ++++++- .../dashboard/blocks/CanvasBlock.test.tsx | 147 +++++++++++++++++ .../src/core/dashboard/blocks/CanvasBlock.tsx | 34 +++- .../src/core/dashboard/blocks/FlexCanvas.tsx | 152 ++++++++++++++++++ .../src/core/dashboard/layoutStyle.test.ts | 105 ++++++++++++ .../src/core/dashboard/layoutStyle.ts | 83 ++++++++++ .../LayoutModeSwitcher.test.tsx | 96 +++++++++++ .../DashboardBuilderV2/LayoutModeSwitcher.tsx | 131 +++++++++++++++ .../src/pages/DashboardBuilderV2/index.tsx | 21 +++ 9 files changed, 817 insertions(+), 7 deletions(-) create mode 100644 superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx create mode 100644 superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx create mode 100644 superset-frontend/src/core/dashboard/layoutStyle.test.ts create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.test.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts index e8cb136ff7c7..9c91f37f1dd2 100644 --- a/superset-frontend/packages/superset-core/src/dashboard/index.ts +++ b/superset-frontend/packages/superset-core/src/dashboard/index.ts @@ -53,6 +53,35 @@ import type { Event } from '../common'; +/** + * How a container arranges its own children. + * + * - `grid` (the default, and what every node written before this field + * existed still means) — children occupy cells of the container's column + * grid and are compacted upward, so the space a removed or moved block + * leaves behind closes itself. + * - `free` — the same cells, uncompacted. A child stays exactly where it was + * put and may overlap a sibling. It reads the same four child fields + * `grid` does, so switching a container between the two never discards a + * position an author or an agent set. + * - `flex` — children flow along a line and wrap, sharing that line in + * proportion to their `colSpan` instead of occupying named cells. Their + * position is their order in `children`, not a coordinate. + * + * A flow is still not a mode, and the two that are here are the two that + * genuinely could not be expressed as a grid: `free` because compaction is a + * property of the container rather than of any child's coordinates, and + * `flex` because a proportional line has no cells to name. + */ +export type LayoutMode = 'grid' | 'free' | 'flex'; + +/** Where `flex` children sit along the line they flow down. */ +export type FlexJustify = + 'start' | 'center' | 'end' | 'space-between' | 'space-around'; + +/** Where `flex` children sit across the line they flow down. */ +export type FlexAlign = 'start' | 'center' | 'end' | 'stretch'; + /** * 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 @@ -60,14 +89,16 @@ import type { Event } from '../common'; * 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. + * There is no separate "flow" 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. {@link LayoutMode} exists only for the arrangements that do not. */ export interface LayoutProps { // --- Container side: how this node arranges its own children. Ignored // on a node with no `children`. --- + /** How this node arranges its children. Default: `grid`. */ + mode?: LayoutMode; /** Number of equal fractional column tracks. Default: 24. */ columns?: number; gap?: number; @@ -79,8 +110,22 @@ export interface LayoutProps { */ rowUnit?: number; + // --- Container side, `flex` only. Ignored in every other mode, rather + // than quietly changing what a grid does. --- + /** Which way `flex` children flow. Default: `row`. */ + direction?: 'row' | 'column'; + /** Whether a `flex` line wraps once it is full. Default: true. */ + wrap?: boolean; + justify?: FlexJustify; + align?: FlexAlign; + // --- 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). */ + /** + * How many of the parent's columns this node spans. Default: every column + * (full width). In a `flex` parent there are no columns to occupy, so this + * is read as the node's share of the line instead — two siblings at 6 and + * 12 take a third and two thirds of it. + */ colSpan?: number; /** How many row tracks this node spans. Default: 1. */ rowSpan?: number; diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx new file mode 100644 index 000000000000..660f36107d3e --- /dev/null +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx @@ -0,0 +1,147 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { fireEvent, render, screen } from 'spec/helpers/testing-library'; +import DashboardProvider from '../DashboardProvider'; +import CanvasBlock from './CanvasBlock'; + +/** + * Which renderer a container gets, and what a gesture in it commits. + * + * `react-grid-layout` is mocked down to the props that decide the two grid + * modes apart. Everything about how it draws is its own business and covered + * by its own tests; what matters here is that `free` reaches it with + * compaction off and overlap allowed, and that `grid` does not — because + * that single pair of props is the whole difference between "the space above + * a block closes" and "a block stays where it was put". + */ +jest.mock('react-grid-layout/legacy', () => ({ + __esModule: true, + default: ({ + children, + compactType, + allowOverlap, + }: { + children: React.ReactNode; + compactType: string | null; + allowOverlap?: boolean; + }) => ( +
+ {children} +
+ ), + WidthProvider: (component: unknown) => component, +})); +jest.mock('react-grid-layout/css/styles.css', () => ({}), { virtual: true }); + +const provider = DashboardProvider.getInstance(); + +beforeEach(() => { + provider.reset(); +}); + +const withMode = (mode?: 'grid' | 'free' | 'flex') => { + const rootId = provider.getRoot().id; + if (mode) { + provider.updateLayout(rootId, { mode }); + } + const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' }); + render(); + return { rootId, first, second }; +}; + +test('a grid compacts its children and does not let them overlap', () => { + withMode('grid'); + + const grid = screen.getByTestId('rgl'); + expect(grid).toHaveAttribute('data-compact-type', 'vertical'); + expect(grid).toHaveAttribute('data-allow-overlap', 'false'); +}); + +test('a container that named no mode is drawn as a grid', () => { + withMode(); + + expect(screen.getByTestId('rgl')).toHaveAttribute( + 'data-compact-type', + 'vertical', + ); +}); + +test('a free canvas turns compaction off and allows overlap', () => { + withMode('free'); + + // `allowOverlap` is what makes a free canvas work. Passing `compactType` + // null on its own leaves react-grid-layout's collision resolution running + // with nothing to settle it, which is the runaway displacement recorded in + // CanvasBlock — a free canvas must never reach that path. + const grid = screen.getByTestId('rgl'); + expect(grid).toHaveAttribute('data-compact-type', 'null'); + expect(grid).toHaveAttribute('data-allow-overlap', 'true'); +}); + +test('a flex container is not a grid at all', () => { + withMode('flex'); + + // A flex line has no cells to give react-grid-layout coordinates in, so + // this is a different renderer rather than the same one configured + // differently. + expect(screen.getByTestId('flex-canvas')).toBeInTheDocument(); + expect(screen.queryByTestId('rgl')).not.toBeInTheDocument(); +}); + +test('dragging one flex child onto another reorders them', () => { + const { rootId, first, second } = withMode('flex'); + const data = new Map(); + const dataTransfer = { + setData: (type: string, value: string) => data.set(type, value), + getData: (type: string) => data.get(type) ?? '', + effectAllowed: '', + }; + + fireEvent.dragStart(screen.getByTestId(`flex-child-${second}`), { + dataTransfer, + }); + fireEvent.drop(screen.getByTestId(`flex-child-${first}`), { dataTransfer }); + + // Position in a flex container is order, so the gesture that arranges one + // is a reorder — and it commits through the same moveBuildingBlock the AI + // tools call. + expect(provider.getNode(rootId)?.children).toEqual([second, first]); +}); + +test('a flex child dropped on itself changes nothing', () => { + const { rootId, first, second } = withMode('flex'); + const data = new Map(); + const dataTransfer = { + setData: (type: string, value: string) => data.set(type, value), + getData: (type: string) => data.get(type) ?? '', + effectAllowed: '', + }; + + fireEvent.dragStart(screen.getByTestId(`flex-child-${first}`), { + dataTransfer, + }); + fireEvent.drop(screen.getByTestId(`flex-child-${first}`), { dataTransfer }); + + expect(provider.getNode(rootId)?.children).toEqual([first, second]); +}); diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx index 380beeed766f..3ab27e20dcf8 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx @@ -27,9 +27,10 @@ import 'react-grid-layout/css/styles.css'; import type { dashboard as dashboardApi } from '@apache-superset/core'; import { useTheme } from '@apache-superset/core/theme'; import { provider, useDashboardRevision } from '../store'; -import { resolveGridMetrics } from '../layoutStyle'; +import { resolveGridMetrics, resolveLayoutMode } from '../layoutStyle'; import { packChildLayout } from '../gridPacking'; import BuildingBlockView from '../BuildingBlockView'; +import FlexCanvas from './FlexCanvas'; type LayoutProps = dashboardApi.LayoutProps; @@ -203,8 +204,30 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { if (!node) return null; + const mode = resolveLayoutMode(node.layout); const { columns, gap, rowUnitPx } = resolveGridMetrics(node.layout, theme); const children = node.children ?? []; + + // A flex line has no cells, so it is not a grid with different settings — + // it is a different renderer. Everything below this point is grid geometry. + if (mode === 'flex') { + return ( + + ); + } + + /** + * `free` is `grid` with the compaction turned off. + * + * `allowOverlap` is what makes that work, and it is not the same thing as + * the `compactType={null}` recorded below as unusable: that path keeps + * react-grid-layout's collision resolution running with nothing to settle + * it, which is exactly the runaway displacement it was found to cause. + * `allowOverlap` removes the collision resolution instead, which is what a + * free canvas means — a block stays where it was put, over a sibling if + * that is where the author put it. + */ + const free = mode === 'free'; const packed = packChildLayout(children, columns, provider.getNode); const layout: Layout = children.map(id => ({ i: id, @@ -237,7 +260,14 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { // a few mouse-move events. `"vertical"` resolves the same collision // by moving the sibling down exactly once, by exactly its own // height, every time. - compactType="vertical" + // + // `free` mode does pass `null` here, and is not a counterexample: + // it pairs it with `allowOverlap`, which switches the collision + // resolution off entirely rather than leaving it running with + // nothing to settle it. The failure above is what that unsettled + // path does; a free canvas never enters it. + compactType={free ? null : 'vertical'} + allowOverlap={free} preventCollision={false} resizeHandles={['se', 'sw', 'ne', 'nw']} // A nested canvas that declares its own `rowUnit` independently of diff --git a/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx new file mode 100644 index 000000000000..356be5dd7897 --- /dev/null +++ b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx @@ -0,0 +1,152 @@ +/** + * 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, useState } from 'react'; +import type { DragEvent } from 'react'; +import type { dashboard as dashboardApi } from '@apache-superset/core'; +import { useTheme } from '@apache-superset/core/theme'; +import { provider } from '../store'; +import { + DEFAULT_COLUMNS, + resolveFlexBasis, + resolveFlexMetrics, +} from '../layoutStyle'; +import BuildingBlockView from '../BuildingBlockView'; + +type LayoutProps = dashboardApi.LayoutProps; + +/** Carried on a reorder drag so a drop elsewhere in the app ignores it. */ +const FLEX_MIME = 'application/x-dashboard-flex-child'; + +/** + * A `flex` container: children flow along a line and wrap, sharing it in + * proportion to their `colSpan`. + * + * `react-grid-layout` is deliberately absent here. Every other mode is a + * grid, and RGL's whole model is coordinates in one — a flex line has no + * cells to give it, and asking it to lay out a wrapping proportional flow + * would mean computing the flow ourselves and then telling RGL the answer. + * CSS already does that, correctly, at every width. + * + * Which means position is order, and order is `children`. So the gesture + * that arranges a flex container is a reorder rather than a reposition, and + * it commits through `moveBuildingBlock` — the same call the AI tools use, so + * dragging a block and asking for it to be moved end at the same place. + * Without this a flex container would be a mode an author can see and not + * author in, which is worse than not offering it. + */ +export default function FlexCanvas({ + nodeId, + layout, + childIds, +}: { + nodeId: string; + layout: LayoutProps | undefined; + childIds: readonly string[]; +}) { + const theme = useTheme(); + const metrics = resolveFlexMetrics(layout, theme); + const columns = layout?.columns ?? DEFAULT_COLUMNS; + /** Which child the pointer is currently over, so the drop target is visible. */ + const [over, setOver] = useState(undefined); + + const drop = useCallback( + (event: DragEvent, targetId: string) => { + event.preventDefault(); + setOver(undefined); + const draggedId = event.dataTransfer.getData(FLEX_MIME); + if (draggedId === '' || draggedId === targetId) { + return; + } + const index = childIds.indexOf(targetId); + if (index === -1) { + return; + } + try { + provider.moveBuildingBlock(draggedId, nodeId, index); + } catch { + // Dropped into itself or one of its own descendants. The provider + // refuses it; leaving the tree as it was is the whole handling. + } + }, + [childIds, nodeId], + ); + + return ( +
+ {childIds.map(childId => { + const child = provider.getNode(childId); + const basis = resolveFlexBasis(child?.layout, columns, childIds.length); + return ( +
{ + event.dataTransfer.setData(FLEX_MIME, childId); + event.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={event => { + event.preventDefault(); + setOver(childId); + }} + onDragLeave={() => + setOver(current => (current === childId ? undefined : current)) + } + onDrop={event => drop(event, childId)} + style={{ + // The basis is the share; growing past it would let a wide + // sibling's leftover space silently re-widen a narrow one, so + // what the author set is what is drawn. + flex: `0 0 ${metrics.flexDirection === 'row' ? basis : 'auto'}`, + // Subtracting the gap keeps two halves on one line: a basis of + // 50% twice plus a gap between them is wider than the line. + maxWidth: + metrics.flexDirection === 'row' + ? `calc(${basis} - ${metrics.gap}px)` + : undefined, + height: (child?.layout?.rowSpan ?? 1) * metrics.rowUnitPx, + outline: + over === childId + ? `2px solid ${theme.colorPrimary}` + : undefined, + cursor: 'grab', + }} + > + +
+ ); + })} +
+ ); +} diff --git a/superset-frontend/src/core/dashboard/layoutStyle.test.ts b/superset-frontend/src/core/dashboard/layoutStyle.test.ts new file mode 100644 index 000000000000..3f1de02847b0 --- /dev/null +++ b/superset-frontend/src/core/dashboard/layoutStyle.test.ts @@ -0,0 +1,105 @@ +/** + * 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 { + resolveFlexBasis, + resolveFlexMetrics, + resolveGridMetrics, + resolveLayoutMode, +} from './layoutStyle'; + +const theme = supersetTheme as unknown as Parameters< + typeof resolveGridMetrics +>[1]; + +test('a container that names no mode is a grid', () => { + // The whole of the back-compatibility story: every node authored before + // the field existed, and every AI tool call that still omits it, arranges + // exactly as it did. + expect(resolveLayoutMode(undefined)).toBe('grid'); + expect(resolveLayoutMode({ columns: 12 })).toBe('grid'); +}); + +test('a container that names a mode gets it', () => { + expect(resolveLayoutMode({ mode: 'free' })).toBe('free'); + expect(resolveLayoutMode({ mode: 'flex' })).toBe('flex'); +}); + +test('flex maps the schema names to CSS rather than forwarding them', () => { + const metrics = resolveFlexMetrics( + { + mode: 'flex', + direction: 'column', + justify: 'space-between', + align: 'center', + }, + theme, + ); + + // `start` is the schema's word and `flex-start` is CSS's. A stored layout + // never holds a raw CSS keyword the renderer merely passes through. + expect(metrics.flexDirection).toBe('column'); + expect(metrics.justifyContent).toBe('space-between'); + expect(metrics.alignItems).toBe('center'); +}); + +test('flex falls back rather than letting an unknown value reach the style', () => { + const metrics = resolveFlexMetrics( + { justify: 'sideways' as never, align: 'diagonal' as never }, + theme, + ); + + expect(metrics.justifyContent).toBe('flex-start'); + expect(metrics.alignItems).toBe('stretch'); +}); + +test('flex wraps unless told not to', () => { + expect(resolveFlexMetrics({}, theme).flexWrap).toBe('wrap'); + expect(resolveFlexMetrics({ wrap: false }, theme).flexWrap).toBe('nowrap'); +}); + +test('gap and row height survive a change of mode', () => { + // Switching how a container arranges its children should not change how + // far apart or how tall they are. + const layout = { gap: 24, rowUnit: 40 }; + + expect(resolveFlexMetrics(layout, theme).gap).toBe( + resolveGridMetrics(layout, theme).gap, + ); + expect(resolveFlexMetrics(layout, theme).rowUnitPx).toBe( + resolveGridMetrics(layout, theme).rowUnitPx, + ); +}); + +test('a flex child that was sized takes that share of the line', () => { + expect(resolveFlexBasis({ colSpan: 12 }, 24, 2)).toBe('50%'); + expect(resolveFlexBasis({ colSpan: 6 }, 24, 4)).toBe('25%'); +}); + +test('a flex child that was never sized takes an equal share, not its content', () => { + // The things a dashboard arranges have no intrinsic width — a chart fills + // whatever box it is handed — so content sizing draws a row of four + // sections as four slivers. + expect(resolveFlexBasis(undefined, 24, 4)).toBe('25%'); + expect(resolveFlexBasis({ rowSpan: 3 }, 24, 3)).toBe(`${100 / 3}%`); +}); + +test('a flex child cannot claim more of the line than there is', () => { + expect(resolveFlexBasis({ colSpan: 99 }, 24, 2)).toBe('100%'); +}); diff --git a/superset-frontend/src/core/dashboard/layoutStyle.ts b/superset-frontend/src/core/dashboard/layoutStyle.ts index f151c26db34f..ebed3c08c945 100644 --- a/superset-frontend/src/core/dashboard/layoutStyle.ts +++ b/superset-frontend/src/core/dashboard/layoutStyle.ts @@ -20,6 +20,7 @@ import type { dashboard as dashboardApi } from '@apache-superset/core'; import type { useTheme } from '@apache-superset/core/theme'; type LayoutProps = dashboardApi.LayoutProps; +type LayoutMode = dashboardApi.LayoutMode; type Theme = ReturnType; /** Column count a container falls back to when its layout omits `columns`. */ @@ -27,6 +28,17 @@ export const DEFAULT_COLUMNS = 24; const DEFAULT_GAP = 16; +/** + * The mode a container arranges its children in. + * + * Absent means `grid`, and that is the whole of the back-compatibility story: + * every node authored before the field existed, and every AI tool call that + * still omits it, keeps arranging exactly as it did. + */ +export function resolveLayoutMode(layout: LayoutProps | undefined): LayoutMode { + return layout?.mode ?? 'grid'; +} + /** A container's resolved grid geometry, in the plain numbers `CanvasBlock` feeds to `react-grid-layout` (`cols`/`rowHeight`/`margin`). */ export interface GridMetrics { columns: number; @@ -50,3 +62,74 @@ export function resolveGridMetrics( rowUnitPx: layout?.rowUnit ?? theme.sizeUnit * 8, }; } + +/** The CSS a `flex` container lays its own children out with. */ +export interface FlexMetrics { + flexDirection: 'row' | 'column'; + flexWrap: 'wrap' | 'nowrap'; + justifyContent: string; + alignItems: string; + gap: number; + rowUnitPx: number; +} + +const JUSTIFY: Record = { + start: 'flex-start', + center: 'center', + end: 'flex-end', + 'space-between': 'space-between', + 'space-around': 'space-around', +}; + +const ALIGN: Record = { + start: 'flex-start', + center: 'center', + end: 'flex-end', + stretch: 'stretch', +}; + +/** + * Resolves a `flex` container's geometry. + * + * The names in the schema are mapped here rather than forwarded, so the + * stored layout never holds a raw CSS keyword the renderer merely passes + * through — `start` is the schema's word and `flex-start` is CSS's, and an + * unrecognised value falls back rather than reaching the style attribute. + * + * `gap` and `rowUnit` are shared with the grid modes on purpose: switching a + * container's mode should change how its children are arranged, not how far + * apart or how tall they are. + */ +export function resolveFlexMetrics( + layout: LayoutProps | undefined, + theme: Theme, +): FlexMetrics { + return { + flexDirection: layout?.direction === 'column' ? 'column' : 'row', + flexWrap: layout?.wrap === false ? 'nowrap' : 'wrap', + justifyContent: JUSTIFY[layout?.justify ?? 'start'] ?? 'flex-start', + alignItems: ALIGN[layout?.align ?? 'stretch'] ?? 'stretch', + gap: layout?.gap ?? DEFAULT_GAP, + rowUnitPx: layout?.rowUnit ?? theme.sizeUnit * 8, + }; +} + +/** + * A `flex` child's share of the line, as a CSS `flex-basis` percentage. + * + * A child that was never sized takes an equal share rather than being sized + * by its content: the things a dashboard arranges have no intrinsic width — + * a chart fills whatever box it is handed — so content sizing resolves to no + * width at all, and a row of four sections draws as four slivers. + */ +export function resolveFlexBasis( + layout: LayoutProps | undefined, + columns: number, + siblings: number, +): string { + const span = layout?.colSpan; + if (span == null) { + return `${100 / Math.max(1, siblings)}%`; + } + return `${(Math.min(span, columns) / columns) * 100}%`; +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.test.tsx new file mode 100644 index 000000000000..abafeeb65c6a --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.test.tsx @@ -0,0 +1,96 @@ +/** + * 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 LayoutModeSwitcher from './LayoutModeSwitcher'; + +const provider = DashboardProvider.getInstance(); + +beforeEach(() => { + provider.reset(); +}); + +const mount = () => { + const rootId = provider.getRoot().id; + provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + render(); + return rootId; +}; + +test('a container that never named a mode reads as a grid', () => { + mount(); + + // Not a default the control invents for display: it is the mode the + // container actually arranges in, so the button and the canvas agree. + expect(screen.getByTestId('layout-mode-grid')).toBeChecked(); +}); + +test('choosing a mode writes it to the container', async () => { + const rootId = mount(); + + await userEvent.click(screen.getByTestId('layout-mode-flex')); + + expect(provider.getNode(rootId)?.layout?.mode).toBe('flex'); +}); + +test('the control follows a mode set anywhere else', () => { + const rootId = provider.getRoot().id; + provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + provider.updateLayout(rootId, { mode: 'free' }); + + render(); + + // `updateLayout` is what an AI tool call goes through. Asking the + // assistant for a free canvas and pressing Free are the same edit, so the + // control has to reflect whichever happened last rather than its own idea. + expect(screen.getByTestId('layout-mode-free')).toBeChecked(); +}); + +test('changing the mode leaves every block where it was', async () => { + const rootId = provider.getRoot().id; + const blockId = provider.addBuildingBlock(rootId, 0, { + type: 'markdown', + layout: { col: 3, row: 2, colSpan: 6, rowSpan: 4 }, + }); + render(); + + await userEvent.click(screen.getByTestId('layout-mode-flex')); + await userEvent.click(screen.getByTestId('layout-mode-grid')); + + // Grid and Free read the same four coordinates and Flex ignores them, so + // a round trip through Flex must not be where a position quietly dies. + expect(provider.getNode(blockId)?.layout).toMatchObject({ + col: 3, + row: 2, + colSpan: 6, + rowSpan: 4, + }); +}); + +test('a node that holds no children offers no arrangement', () => { + const rootId = provider.getRoot().id; + const leafId = provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + + render(); + + // A leaf arranges nothing. Offering it a layout mode would be offering a + // setting with nothing to apply to. + expect(screen.queryByTestId('layout-mode-switcher')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx new file mode 100644 index 000000000000..fac397a2d61d --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx @@ -0,0 +1,131 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ReactElement } from 'react'; +import type { dashboard as dashboardApi } from '@apache-superset/core'; +import { t } from '@apache-superset/core/translation'; +import { useTheme } from '@apache-superset/core/theme'; +import { Radio, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { provider, useDashboardRevision } from 'src/core/dashboard/store'; +import { resolveLayoutMode } from 'src/core/dashboard/layoutStyle'; + +type LayoutMode = dashboardApi.LayoutMode; + +/** + * What each mode is for, in the author's terms rather than the schema's. + * + * Every one of them says what happens to a block, because that is the only + * part an author can see: whether the space a block leaves behind closes, and + * whether a block may sit over another. The names on the buttons are short + * enough to read at a glance; the sentence is where the difference lives. + */ +const MODES: readonly { + readonly key: LayoutMode; + readonly label: string; + readonly hint: string; + readonly icon: ReactElement; +}[] = [ + { + key: 'grid', + label: t('Grid'), + hint: t('Blocks snap to columns and close up the space above them.'), + icon: , + }, + { + key: 'flex', + label: t('Flex'), + hint: t('Blocks flow along a line and wrap, sharing it by width.'), + icon: , + }, + { + key: 'free', + label: t('Free'), + hint: t('Blocks stay exactly where you put them, and may overlap.'), + icon: , + }, +]; + +/** + * How one container arranges its children. + * + * The control edits the container's own `layout.mode`, which is the same + * field an AI tool call writes through `dashboard.updateLayout` — so asking + * the assistant for a free canvas and pressing Free here are the same edit, + * and the button reflects whichever of the two happened last. + * + * A mode change moves nothing. Grid and Free read the same four coordinates + * per child, so switching between them only changes whether the container + * compacts them; switching to Flex leaves those coordinates untouched in the + * store, so coming back finds every block where it was left. + */ +export default function LayoutModeSwitcher({ + nodeId, +}: { + nodeId: string; +}): ReactElement | null { + useDashboardRevision(); + const theme = useTheme(); + const node = provider.getNode(nodeId); + if (!node?.children) { + return null; + } + const mode = resolveLayoutMode(node.layout); + + return ( +
+ + {t('Layout')} + + + provider.updateLayout(nodeId, { + mode: event.target.value as LayoutMode, + }) + } + > + {MODES.map(option => ( + + + {option.icon} {option.label} + + + ))} + +
+ ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index efc1db8d7011..2cb30e7a3a4c 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -25,6 +25,7 @@ import { dashboard, useDashboardRevision } from 'src/core/dashboard'; import { chat } from 'src/core/chat'; import BuildingBlockView from 'src/core/dashboard/BuildingBlockView'; import { dashboardClientTools } from './clientTools'; +import LayoutModeSwitcher from './LayoutModeSwitcher'; const PageContainer = styled(Flex)` ${({ theme }) => css` @@ -44,6 +45,17 @@ const Canvas = styled.div` `} `; +const Toolbar = styled.div` + ${({ theme }) => css` + display: flex; + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + padding: ${theme.paddingSM}px ${theme.paddingLG}px; + border-bottom: 1px solid ${theme.colorBorderSecondary}; + `} +`; + const EmptyCanvasWrapper = styled.div` ${({ theme }) => css` height: 100%; @@ -96,6 +108,15 @@ export default function DashboardBuilderV2() { return ( + {/* The one piece of authoring chrome this page owns. It arranges the + root canvas rather than any block, so it belongs to the page and + not to the tree BuildingBlockView renders — and it is hidden while + the dashboard is empty, when there is nothing to arrange. */} + {!isEmpty && ( + + + + )} {isEmpty ? ( From e91358d70f2544fca9c3d888323d2fb9b963091c Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Thu, 6 Aug 2026 01:10:49 +0200 Subject: [PATCH 03/19] fix(dashboard-v2): show the layout control on a blank dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding it until something was on the canvas read as "nothing to arrange yet". The practical effect was the opposite: /dashboard/v2/new/ opens empty by definition, so the control was invisible at the one moment someone would look for it. Setting the arrangement before adding anything is also the ordinary way round — whatever the assistant places next lands in the mode already chosen, rather than being placed and then rearranged. Co-Authored-By: Claude Opus 5 --- .../pages/DashboardBuilderV2/index.test.tsx | 52 +++++++++++++++++++ .../src/pages/DashboardBuilderV2/index.tsx | 19 ++++--- 2 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx new file mode 100644 index 000000000000..af81f7da791e --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx @@ -0,0 +1,52 @@ +/** + * 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 'src/core/dashboard/DashboardProvider'; +import DashboardBuilderV2 from '.'; + +jest.mock('src/core/chat', () => ({ + chat: { registerClientTools: () => ({ dispose: () => {} }) }, +})); + +const provider = DashboardProvider.getInstance(); + +beforeEach(() => { + provider.reset(); +}); + +test('a blank dashboard still offers a layout to arrange it in', () => { + render(); + + // The page a `/dashboard/v2/new/` load lands on has nothing on it yet, and + // that is exactly when someone reaches for the layout control: whatever is + // placed next lands in the mode already chosen, rather than being placed + // and then rearranged. + expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); + expect( + screen.getByText('Blank dashboard — ask the assistant to start building'), + ).toBeInTheDocument(); +}); + +test('the layout control survives the first block being added', () => { + provider.addBuildingBlock(provider.getRoot().id, 0, { type: 'markdown' }); + + render(); + + expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 2cb30e7a3a4c..8b36f7638949 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -110,13 +110,18 @@ export default function DashboardBuilderV2() { {/* The one piece of authoring chrome this page owns. It arranges the root canvas rather than any block, so it belongs to the page and - not to the tree BuildingBlockView renders — and it is hidden while - the dashboard is empty, when there is nothing to arrange. */} - {!isEmpty && ( - - - - )} + not to the tree BuildingBlockView renders. + + Shown on an empty dashboard too. Hiding it until something was on + the canvas read as "nothing to arrange yet", but the practical + effect was that the control was invisible at the one moment + someone opening a blank dashboard would look for it — and setting + the arrangement before adding anything is the ordinary way round: + whatever the assistant places next lands in the mode already + chosen, rather than being placed and then rearranged. */} + + + {isEmpty ? ( From 1765ae1d5e9b04eb79913bf1d38c4ad91b34819f Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Thu, 6 Aug 2026 01:28:13 +0200 Subject: [PATCH 04/19] feat(dashboard-v2): give the builder an editing shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page was a canvas and a chat panel: everything on a dashboard had to be asked for in words. It gains a header and a three-tab panel, so placing a block, editing one and finding one are things an author can also do directly. Selection is what the panel needed and the provider did not have. It lives there as host-internal state beside the revision counter, and for the same reason: it belongs to one person looking at one screen, not to the dashboard, so it is not on the public API. Putting it in the store both layers already subscribe to beats threading it through the render tree BuildingBlockView deliberately keeps ignorant. The palette is the registry — `views.getViews('dashboard.buildingBlocks')`, the same call that resolves a renderer — so registering a block makes it placeable with no list here to keep in agreement. It shelves on the one distinction this fork records: whether placing the type produces something other blocks can go inside. There is deliberately no Extensions shelf, because a registered View says nothing about who contributed it and a dotted-id convention would be a guess dressed as a fact. Every field in Properties writes through updateLayout/updateProps, the same two calls the client tools make, so a change made by hand and one asked for in chat are the same edit arriving by different routes. Most of the header is disabled on purpose. The builder holds its tree in memory with 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. The layout switcher is the exception, and it is live precisely because its state is in the tree. Co-Authored-By: Claude Opus 5 --- .../src/components/Icons/AntdEnhanced.tsx | 6 + .../src/core/dashboard/BuildingBlockView.tsx | 43 +++- .../core/dashboard/DashboardProvider.test.ts | 57 +++++ .../src/core/dashboard/DashboardProvider.ts | 57 ++++- .../DashboardHeader.test.tsx | 70 +++++ .../DashboardBuilderV2/DashboardHeader.tsx | 147 +++++++++++ .../DashboardBuilderV2/EditorPanel.test.tsx | 193 ++++++++++++++ .../pages/DashboardBuilderV2/EditorPanel.tsx | 233 +++++++++++++++++ .../pages/DashboardBuilderV2/Inspector.tsx | 240 +++++++++++++++++ .../src/pages/DashboardBuilderV2/Outline.tsx | 157 ++++++++++++ .../src/pages/DashboardBuilderV2/Palette.tsx | 241 ++++++++++++++++++ .../pages/DashboardBuilderV2/index.test.tsx | 69 ++++- .../src/pages/DashboardBuilderV2/index.tsx | 114 +++++---- 13 files changed, 1580 insertions(+), 47 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 81c22d711d9b..dbd41a66b76b 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -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, diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index 56764220514a..2a22d4884e06 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -86,13 +86,54 @@ interface BuildingBlockViewProps extends HTMLAttributes { const BuildingBlockView = forwardRef( function BuildingBlockView({ nodeId, children, ...rest }, ref) { useDashboardRevision(); + const theme = useTheme(); const node = provider.getNode(nodeId); if (!node) return null; const resolved = resolveBuildingBlockView(node.type, nodeId); + const selected = provider.getSelection() === nodeId; return ( -
+
{ + event.stopPropagation(); + provider.setSelection(nodeId); + }} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + provider.setSelection(nodeId); + } + }} + style={{ + ...rest.style, + // Drawn over the block rather than around it: an outline takes no + // space, so nothing on screen shifts when a selection moves. + outline: selected ? `2px solid ${theme.colorPrimary}` : undefined, + outlineOffset: selected ? -2 : undefined, + }} + >
{resolved ?? } diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts index 887f1a058646..63f0bc5c6cc5 100644 --- a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts +++ b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts @@ -331,3 +331,60 @@ test('getRevision increments on every mutation and is stable otherwise', () => { 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: 'canvas', + }); + 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(); +}); diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.ts b/superset-frontend/src/core/dashboard/DashboardProvider.ts index 4cc7a9afe0b0..f3d8a9c0e83e 100644 --- a/superset-frontend/src/core/dashboard/DashboardProvider.ts +++ b/superset-frontend/src/core/dashboard/DashboardProvider.ts @@ -31,6 +31,20 @@ type StoredNode = Omit; const ROOT_ID = 'root'; +/** + * The one node type that holds other nodes. + * + * Named here because two things need to agree on it and neither should learn + * it by string comparison of its own: this provider, deciding whether a new + * node gets a `children` array at all, and the palette, deciding whether a + * block an author places is a container or something to put in one. + */ +export const CONTAINER_TYPE = 'canvas'; + +/** Whether placing this type produces something other nodes can go inside. */ +export const isContainerType = (type: string): boolean => + type === CONTAINER_TYPE; + function createBlankNodes(): Record { return { [ROOT_ID]: { @@ -68,6 +82,22 @@ class DashboardProvider { 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(); private stateSubscribers = new Set<() => void>(); @@ -86,7 +116,31 @@ class DashboardProvider { 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): 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(); @@ -180,7 +234,7 @@ class DashboardProvider { layout: spec.layout, props: spec.props, style: spec.style, - ...(spec.type === 'canvas' ? { children: [] } : {}), + ...(isContainerType(spec.type) ? { children: [] } : {}), }; const children = [...parent.children]; @@ -348,6 +402,7 @@ class DashboardProvider { /** 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(); this.stateSubscribers.clear(); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx new file mode 100644 index 000000000000..61293bb68755 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import 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(); + +beforeEach(() => { + provider.reset(); +}); + +test('the header carries the dashboard-level affordances', () => { + render(); + + 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-refresh')).toBeInTheDocument(); + 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', () => { + render(); + + // 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-refresh', + 'header-undo', + 'header-redo', + 'header-save', + ].forEach(test => expect(screen.getByTestId(test)).toBeDisabled()); +}); + +test('the layout switcher is the one live control, and it edits the tree', async () => { + render(); + const rootId = provider.getRoot().id; + + // Live precisely because its state is in the tree rather than in a row + // this page does not have. + await userEvent.click(screen.getByTestId('layout-mode-flex')); + + expect(provider.getNode(rootId)?.layout?.mode).toBe('flex'); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx new file mode 100644 index 000000000000..1ba36d59bd4e --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx @@ -0,0 +1,147 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ReactElement, ReactNode } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { useTheme } from '@apache-superset/core/theme'; +import { + Button, + type ButtonProps, + PublishedLabel, +} from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { provider, useDashboardRevision } from 'src/core/dashboard/store'; +import LayoutModeSwitcher from './LayoutModeSwitcher'; + +const NOT_AVAILABLE = t('Not available yet'); + +/** + * An affordance that is present, named and honest about not working. + * + * Most of this header is one. The builder keeps its tree in memory and has + * no dashboard row behind it: nothing here 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. + */ +const Inert = ({ + label, + test, + buttonStyle, + children, +}: { + label: string; + test: string; + buttonStyle?: ButtonProps['buttonStyle']; + children: ReactNode; +}): ReactElement => ( + +); + +/** + * 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, whether it + * is published. On the right is what an author does to the tree in front of + * them, and the one live control among them is the layout: it is the only one + * whose state is in the tree rather than in a row this page does not have. + */ +export default function DashboardHeader(): ReactElement { + useDashboardRevision(); + const theme = useTheme(); + const root = provider.getRoot(); + + return ( +
+ {/* Where this dashboard came from and where it has been: one offers a + starting point to build from, the other the record of what has + already happened to it. Both are asked before the work rather than + during it, which is why they lead the bar. */} + + {t('Templates')} + + + {t('History')} + + + + + {/* Nothing here can publish, so the chip states the only status this + page can honestly claim. */} + + + + + + + + + + {/* 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. */} + + + + + + + + {t('Save')} + + +
+ ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx new file mode 100644 index 000000000000..5260047624fd --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx @@ -0,0 +1,193 @@ +/** + * 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(); + 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('a canvas is shelved as structure and everything else as content', () => { + mount(); + + // The one distinction this fork records: whether placing the type produces + // something other blocks can go inside. + expect(screen.getByTestId('palette-shelf-structure')).toContainElement( + screen.getByTestId('palette-canvas'), + ); + expect(screen.getByTestId('palette-shelf-content')).toContainElement( + screen.getByTestId('palette-markdown'), + ); +}); + +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(500); +}); + +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(516); + + 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', + '500', + ); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx new file mode 100644 index 000000000000..0007fc403c9e --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx @@ -0,0 +1,233 @@ +/** + * 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 { useTheme } from '@apache-superset/core/theme'; +import { Tabs } from '@superset-ui/core/components'; +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 = 500; +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 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 theme = useTheme(); + const [tab, setTab] = useState('blocks'); + const [width, setWidth] = useState(DEFAULT_WIDTH); + /** Whether the grip is showing itself — under the pointer, or focused. */ + const [gripped, setGripped] = useState(false); + const panel = useRef(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): void => { + from.current = { x: event.clientX, width }; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [width], + ); + + const drag = (event: PointerEvent): void => { + if (from.current !== null) { + setWidth(clampWidth(from.current.width + event.clientX - from.current.x)); + } + }; + + const endDrag = (event: PointerEvent): void => { + from.current = null; + event.currentTarget.releasePointerCapture?.(event.pointerId); + }; + + const nudge = (event: KeyboardEvent): void => { + const moves: Record 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))); + } + }; + + return ( + + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx new file mode 100644 index 000000000000..366a8b296001 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx @@ -0,0 +1,240 @@ +/** + * 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 { useTheme } from '@apache-superset/core/theme'; +import { Button, Form, Input, InputNumber } from '@superset-ui/core/components'; +import { provider, useDashboardRevision } from 'src/core/dashboard/store'; +import LayoutModeSwitcher from './LayoutModeSwitcher'; + +type LayoutProps = dashboardApi.LayoutProps; + +/** Which layout fields belong to a container, and which to its child. */ +const CONTAINER_FIELDS: readonly { + readonly key: keyof LayoutProps; + readonly label: string; +}[] = [ + { key: 'columns', label: t('Columns') }, + { key: 'gap', label: t('Gap') }, + { key: 'rowUnit', label: t('Row height') }, +]; + +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') }, +]; + +const Section = ({ + title, + test, + children, +}: { + title: string; + test: string; + children: ReactNode; +}): ReactElement => { + const theme = useTheme(); + return ( +
+

+ {title} +

+ {children} +
+ ); +}; + +/** + * 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 => ( + + onChange(typeof next === 'number' ? next : undefined)} + /> + +); + +/** The `content` a markdown block renders, edited where it is displayed. */ +const ContentField = ({ + nodeId, + content, +}: { + nodeId: string; + content: string; +}): ReactElement => { + 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 ( + + setDraft(event.target.value)} + onBlur={() => { + if (draft !== content) { + provider.updateProps(nodeId, { content: draft }); + } + }} + /> + + ); +}; + +/** + * 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); + + if (!node) { + return ( +

+ {t('Select a block to edit its properties.')} +

+ ); + } + + const isContainer = node.children !== undefined; + const content = node.props?.content; + + return ( +
+

+ {node.type} · {node.id} +

+ + {/* Labels above their fields: beside them halves the width left for the + control, in the panel that most needs the room. */} +
+ {typeof content === 'string' && ( +
+ +
+ )} + + {isContainer && ( +
+ +
+ {CONTAINER_FIELDS.map(field => ( + + provider.updateLayout(node.id, { [field.key]: next }) + } + /> + ))} +
+
+ )} + +
+ {CHILD_FIELDS.map(field => ( + + provider.updateLayout(node.id, { [field.key]: next }) + } + /> + ))} +
+
+ + +
+ ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx new file mode 100644 index 000000000000..857157d37032 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx @@ -0,0 +1,157 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ReactElement } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { useTheme } from '@apache-superset/core/theme'; +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 | 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; +}; + +const Row = ({ + nodeId, + depth, +}: { + nodeId: string; + depth: number; +}): ReactElement | null => { + const theme = useTheme(); + const node = provider.getNode(nodeId); + if (!node) { + return null; + } + const selected = provider.getSelection() === nodeId; + const children = node.children ?? []; + + return ( +
  • +
    provider.setSelection(nodeId)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + provider.setSelection(nodeId); + } + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: theme.sizeUnit, + padding: `${theme.sizeUnit / 2}px ${theme.sizeUnit}px`, + paddingLeft: theme.sizeUnit * (1 + depth * 3), + borderRadius: theme.borderRadius, + fontSize: theme.fontSizeSM, + color: selected ? theme.colorPrimaryText : theme.colorText, + background: selected ? theme.colorPrimaryBg : undefined, + cursor: 'pointer', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {labelOf(node.type, node.props)} +
    + {children.length > 0 && ( +
      + {children.map(childId => ( + + ))} +
    + )} +
  • + ); +}; + +/** + * 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 theme = useTheme(); + const root = provider.getRoot(); + const children = root.children ?? []; + + if (children.length === 0) { + return ( +

    + {t('Nothing on the dashboard yet.')} +

    + ); + } + + return ( +
      + {children.map(childId => ( + + ))} +
    + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx new file mode 100644 index 000000000000..91e5d4e90393 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx @@ -0,0 +1,241 @@ +/** + * 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 { t } from '@apache-superset/core/translation'; +import { useTheme } from '@apache-superset/core/theme'; +import { Input } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { views } from 'src/core/views'; +import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView'; +import { isContainerType } from 'src/core/dashboard/DashboardProvider'; + +/** + * 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. */ +export const paletteEntries = (): readonly PaletteEntry[] => + (views.getViews(DASHBOARD_BUILDING_BLOCKS_LOCATION) ?? []).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) + ); +}; + +/** + * 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 => { + const theme = useTheme(); + return ( +
    + + {open &&
    {children}
    } +
    + ); +}; + +/** + * The building blocks, as things to place. + * + * The list is the registry's — `views.getViews('dashboard.buildingBlocks')`, + * the same call `BuildingBlockView` resolves a renderer through. Registering + * a block makes it placeable and unregistering one removes it, with no list + * here to keep in agreement. + */ +export default function Palette({ + onAdd, +}: { + onAdd: (type: string) => void; +}): ReactElement { + const theme = useTheme(); + const [query, setQuery] = useState(''); + const [closed, setClosed] = useState>(new Set()); + + const entries = useMemo(paletteEntries, []); + 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 ( +
    + } + onChange={event => setQuery(event.target.value)} + /> + {found.length === 0 ? ( +

    + {t('No building block matches “%s”.', query)} +

    + ) : ( + 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 ( + toggle(shelf.key)} + > + {onShelf.map(entry => ( + + ))} + + ); + }) + )} +
    + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx index af81f7da791e..a08195d16b2b 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx @@ -16,6 +16,7 @@ * 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 DashboardBuilderV2 from '.'; @@ -38,8 +39,10 @@ test('a blank dashboard still offers a layout to arrange it in', () => { // placed next lands in the mode already chosen, rather than being placed // and then rearranged. expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); + // The canvas is no longer chat-only: a palette sits beside it, so the + // empty state names both ways in. expect( - screen.getByText('Blank dashboard — ask the assistant to start building'), + screen.getByText('Add a building block, or ask the assistant to start'), ).toBeInTheDocument(); }); @@ -48,5 +51,67 @@ test('the layout control survives the first block being added', () => { render(); - expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); + expect(screen.getAllByTestId('layout-mode-switcher').length).toBeGreaterThan( + 0, + ); +}); + +test('the page is a header, an editor panel and a canvas', () => { + render(); + + 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 () => { + render(); + + 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]); +}); + +test('a block placed while a container is selected goes inside it', async () => { + render(); + await userEvent.click(screen.getByTestId('palette-canvas')); + const sectionId = provider.getSelection()!; + + await userEvent.click(screen.getByTestId('palette-markdown')); + + // An author who has just selected a section and reaches for a block means + // to put it in that section. + expect(provider.getNode(sectionId)?.children).toEqual([ + provider.getSelection(), + ]); + expect(provider.getRoot().children).toEqual([sectionId]); +}); + +test('a block placed while a leaf is selected goes beside it, not inside it', async () => { + render(); + 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 () => { + render(); + 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(); }); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 8b36f7638949..25531723818c 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -22,10 +22,13 @@ import { css, styled } from '@apache-superset/core/theme'; import { Flex, Typography } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { dashboard, useDashboardRevision } from 'src/core/dashboard'; +import { provider } from 'src/core/dashboard/store'; +import { isContainerType } from 'src/core/dashboard/DashboardProvider'; import { chat } from 'src/core/chat'; import BuildingBlockView from 'src/core/dashboard/BuildingBlockView'; import { dashboardClientTools } from './clientTools'; -import LayoutModeSwitcher from './LayoutModeSwitcher'; +import DashboardHeader from './DashboardHeader'; +import EditorPanel from './EditorPanel'; const PageContainer = styled(Flex)` ${({ theme }) => css` @@ -45,15 +48,10 @@ const Canvas = styled.div` `} `; -const Toolbar = styled.div` - ${({ theme }) => css` - display: flex; - align-items: center; - justify-content: flex-end; - flex: 0 0 auto; - padding: ${theme.paddingSM}px ${theme.paddingLG}px; - border-bottom: 1px solid ${theme.colorBorderSecondary}; - `} +const Workspace = styled.div` + display: flex; + flex: 1 1 auto; + min-height: 0; `; const EmptyCanvasWrapper = styled.div` @@ -106,41 +104,71 @@ export default function DashboardBuilderV2() { const root = dashboard.getRoot(); const isEmpty = !root.children || root.children.length === 0; + /** + * 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. + * + * The new block is then selected, because placing something is the moment + * you want to configure it — which is also what brings Properties forward. + */ + const addBlock = (type: string): void => { + const selected = provider.getSelection(); + const selectedNode = + selected === undefined ? undefined : provider.getNode(selected); + const parentId = + selectedNode?.children !== undefined ? selectedNode.id : root.id; + const index = provider.getNode(parentId)?.children?.length ?? 0; + const id = dashboard.addBuildingBlock(parentId, index, { + type, + // A container arrives with the grid every other container defaults to, + // so a nested canvas is usable the moment it is placed rather than + // needing its columns set before anything can go in it. + ...(isContainerType(type) + ? { layout: { columns: 24, gap: 16, colSpan: 24, rowSpan: 4 } } + : {}), + }); + provider.setSelection(id); + }; + return ( - {/* The one piece of authoring chrome this page owns. It arranges the - root canvas rather than any block, so it belongs to the page and - not to the tree BuildingBlockView renders. - - Shown on an empty dashboard too. Hiding it until something was on - the canvas read as "nothing to arrange yet", but the practical - effect was that the control was invisible at the one moment - someone opening a blank dashboard would look for it — and setting - the arrangement before adding anything is the ordinary way round: - whatever the assistant places next lands in the mode already - chosen, rather than being placed and then rearranged. */} - - - - - {isEmpty ? ( - - - - - {t('Blank dashboard — ask the assistant to start building')} - - - - ) : ( - - )} - + + + + { + // 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 ? ( + + + + + {t('Add a building block, or ask the assistant to start')} + + + + ) : ( + + )} + + ); } From 07a6a218df5d163ab7e6d334a0a3c72cc9d532a4 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Thu, 6 Aug 2026 09:07:34 +0200 Subject: [PATCH 05/19] feat(dashboard-v2): let the dashboard be named, beside History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header had no name in it because this fork has nowhere to keep one: no dashboard row, and a deliberate decision that a title is a `markdown` block placed on the canvas like any other content. That decision stands and this is a different thing. A markdown title is content — arranged, positioned, and one block among many. A name is what the dashboard is called, and it belongs to the dashboard rather than to its contents. So it is stored on the root node, which is the only node a fact about the dashboard itself can belong to. Page state was the alternative and a worse one: a name held there is invisible to the assistant, unreachable by the client tools, and gone on the next navigation. The draft commits on blur, because a name being typed is not a name and a commit per keystroke is a revision tick per keystroke for everything subscribed. An emptied field restores rather than writing the blank: a stray select-all-and-delete must not silently leave the dashboard nameless. Co-Authored-By: Claude Opus 5 --- .../superset-core/src/dashboard/index.ts | 11 +++- .../DashboardHeader.test.tsx | 32 +++++++++++ .../DashboardBuilderV2/DashboardHeader.tsx | 54 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts index 9c91f37f1dd2..eee98aa10a9d 100644 --- a/superset-frontend/packages/superset-core/src/dashboard/index.ts +++ b/superset-frontend/packages/superset-core/src/dashboard/index.ts @@ -157,7 +157,16 @@ export interface DashboardNode { * repositioning a node on the canvas never changes this array. */ children?: string[]; - /** Leaf/building-block nodes only — functional/content config. */ + /** + * Functional/content config. Leaf/building-block nodes carry whatever + * their renderer reads. + * + * The root `canvas` is the one container that also carries some: it is the + * only node a fact about the dashboard *itself* can belong to, so that is + * where its `title` lives. A title placed as a `markdown` block is a + * different thing — that one is content, arranged like any other block; + * this one is what the dashboard is called. + */ props?: Record; /** Leaf/building-block nodes only — visual customization. */ style?: Record; diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx index 61293bb68755..4642cf629556 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx @@ -68,3 +68,35 @@ test('the layout switcher is the one live control, and it edits the tree', async expect(provider.getNode(rootId)?.layout?.mode).toBe('flex'); }); + +test('the dashboard is nameable, and the name is stored on the dashboard', async () => { + render(); + + 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' }); + + render(); + + 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' }); + render(); + + 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'); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx index 1ba36d59bd4e..4efb68c821ad 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx @@ -16,12 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import { useEffect, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { t } from '@apache-superset/core/translation'; import { useTheme } from '@apache-superset/core/theme'; import { Button, type ButtonProps, + Input, PublishedLabel, } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; @@ -67,6 +69,54 @@ const Inert = ({ ); +/** + * 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 ( + 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 dashboard's header: what this dashboard is, and what can be done to it. * @@ -104,6 +154,10 @@ export default function DashboardHeader(): ReactElement { {t('History')} + <Inert label={t('Favorite')} test="header-favorite" buttonStyle="link"> <Icons.StarOutlined iconSize="l" /> </Inert> From 6303bb27671b739291a81db3eb787d63d369c710 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 09:23:02 +0200 Subject: [PATCH 06/19] feat(dashboard-v2): let a block be given its content in Properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block placed from the palette could not be authored. Markdown arrives with no props at all, so the Content field — which waited for a `content` key to already exist — never appeared, and the one prop that block reads had no way in. Chart, table and metric tile were worse off still: their dataBinding, echartsOptions and columnDefs had never had a hand-editing path of any kind. Properties now carries both. A prose field for the types whose renderer reads plain text, offered whether or not there is any yet. And a JSON editor over the node's whole props, which is the general answer and general on purpose: every key any block reads, including whatever an extension registers next year, without this panel learning a single type — the exact knowledge BuildingBlockView is built not to have. A draft is held until it parses and the author asks for it, so malformed JSON never reaches a block and a half-typed edit is never taken away. A key the author deleted is sent as `undefined`, which is as close to a removal as a merge can express; omitting it would silently do nothing and the block would go on rendering from the value it appeared to lose. The panel is also set down from the tab bar. Flush against it, the first line read as a caption belonging to the tabs rather than to the block it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../DashboardBuilderV2/Inspector.test.tsx | 156 ++++++++++++++++++ .../pages/DashboardBuilderV2/Inspector.tsx | 151 ++++++++++++++++- 2 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx new file mode 100644 index 000000000000..3838126e8e91 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx @@ -0,0 +1,156 @@ +/** + * 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 'src/core/dashboard'; +import Inspector from './Inspector'; + +const provider = DashboardProvider.getInstance(); + +beforeEach(() => { + provider.reset(); +}); + +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', () => { + 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(screen.getByTestId('inspector-props')).toBeInTheDocument(); +}); + +test('applying properties writes them to the block', async () => { + const id = select('echarts'); + + 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 }); + + 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', () => { + const id = select('echarts', { kept: true }); + + 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', () => { + select('echarts'); + + fireEvent.change(screen.getByTestId('inspector-props'), { + target: { value: '[1, 2, 3]' }, + }); + + expect(screen.getByTestId('inspector-props-apply')).toBeDisabled(); +}); + +test('reverting restores what the block still has', async () => { + select('echarts', { kept: true }); + + 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', + ); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx index 366a8b296001..78b5e39430da 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx @@ -104,7 +104,17 @@ const NumberField = ({ </Form.Item> ); -/** The `content` a markdown block renders, edited where it is displayed. */ +/** + * 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, @@ -135,6 +145,111 @@ const ContentField = ({ ); }; +const format = (props: Record<string, unknown> | undefined): string => + JSON.stringify(props ?? {}, null, 2); + +/** + * Everything a block renders from, offered whole. + * + * 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. + * + * 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 PropsEditor = ({ + 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]); + + 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 ( + <> + <Form.Item label={t('Properties (JSON)')} style={{ marginBottom: 8 }}> + <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 + size="small" + 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> + <Button + size="small" + data-test="inspector-props-revert" + disabled={!dirty} + onClick={() => setDraft(accepted)} + > + {t('Revert')} + </Button> + </div> + </> + ); +}; + /** * Property editing over the selected node. * @@ -153,11 +268,21 @@ export default function Inspector(): ReactElement { 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 ( <p data-test="inspector-empty" - style={{ color: theme.colorTextTertiary, fontSize: theme.fontSizeSM }} + style={{ + ...inset, + margin: 0, + color: theme.colorTextTertiary, + fontSize: theme.fontSizeSM, + }} > {t('Select a block to edit its properties.')} </p> @@ -166,9 +291,15 @@ export default function Inspector(): ReactElement { const isContainer = node.children !== undefined; 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={{ fontSize: theme.fontSizeSM }}> + <div data-test="inspector" style={{ ...inset, fontSize: theme.fontSizeSM }}> <p data-test="inspector-identity" style={{ @@ -183,11 +314,15 @@ export default function Inspector(): ReactElement { {/* Labels above their fields: beside them halves the width left for the control, in the panel that most needs the room. */} <Form layout="vertical" component="div"> - {typeof content === 'string' && ( - <Section title={t('Content')} test="inspector-section-content"> - <ContentField nodeId={node.id} content={content} /> - </Section> - )} + <Section title={t('Content')} test="inspector-section-content"> + {takesText && ( + <ContentField + nodeId={node.id} + content={typeof content === 'string' ? content : ''} + /> + )} + <PropsEditor nodeId={node.id} props={node.props} /> + </Section> {isContainer && ( <Section From ee298632fbb3de7b089719ee1e74203bf4d69edf Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 09:29:31 +0200 Subject: [PATCH 07/19] style(dashboard-v2): size the header controls down The bar is chrome around the work rather than the work itself, and every pixel it takes is one the canvas does not get. Heights come from the theme's own control steps rather than literals, so they track the scale the rest of the app is built on instead of drifting from it. The icons and the title field come down with the buttons: a control left at its old size beside smaller ones reads as a different kind of thing rather than the same kind, larger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../DashboardBuilderV2/DashboardHeader.tsx | 56 +++++++++++++------ .../DashboardBuilderV2/LayoutModeSwitcher.tsx | 9 +++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx index 4efb68c821ad..00d04c102a56 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx @@ -32,6 +32,21 @@ import LayoutModeSwitcher from './LayoutModeSwitcher'; const NOT_AVAILABLE = t('Not available yet'); +/** + * Header controls, sized down. + * + * The bar is chrome around the work rather than the work itself, and every + * pixel it takes is one the canvas does not get. Driven from the theme's own + * smallest control step rather than a literal, so it tracks the scale the + * rest of the app is built on instead of drifting from it. + */ +const compact = (theme: ReturnType<typeof useTheme>) => ({ + height: theme.controlHeightXS, + paddingInline: theme.sizeUnit * 1.5, + fontSize: theme.fontSizeSM, + lineHeight: 1, +}); + /** * An affordance that is present, named and honest about not working. * @@ -55,19 +70,23 @@ const Inert = ({ test: string; buttonStyle?: ButtonProps['buttonStyle']; children: ReactNode; -}): ReactElement => ( - <Button - size="small" - buttonStyle={buttonStyle} - disabled - aria-label={label} - data-test={test} - tooltip={`${label} — ${NOT_AVAILABLE}`} - placement="bottom" - > - {children} - </Button> -); +}): ReactElement => { + const theme = useTheme(); + return ( + <Button + size="small" + buttonStyle={buttonStyle} + disabled + aria-label={label} + data-test={test} + tooltip={`${label} — ${NOT_AVAILABLE}`} + placement="bottom" + style={compact(theme)} + > + {children} + </Button> + ); +}; /** * The dashboard's name, edited where it is read. @@ -88,6 +107,7 @@ const Inert = ({ * tick per character for everything subscribed to the store. */ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { + const theme = useTheme(); 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. @@ -96,7 +116,7 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { return ( <Input size="small" - style={{ maxWidth: 260 }} + style={{ maxWidth: 220, height: theme.controlHeightSM }} value={draft} aria-label={t('Dashboard title')} placeholder={t('Untitled dashboard')} @@ -159,7 +179,7 @@ export default function DashboardHeader(): ReactElement { title={typeof root.props?.title === 'string' ? root.props.title : ''} /> <Inert label={t('Favorite')} test="header-favorite" buttonStyle="link"> - <Icons.StarOutlined iconSize="l" /> + <Icons.StarOutlined iconSize="m" /> </Inert> {/* Nothing here can publish, so the chip states the only status this page can honestly claim. */} @@ -181,16 +201,16 @@ export default function DashboardHeader(): ReactElement { test="header-refresh" buttonStyle="link" > - <Icons.ReloadOutlined iconSize="l" /> + <Icons.ReloadOutlined iconSize="m" /> </Inert> {/* 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="m" /> + <Icons.UndoOutlined iconSize="s" /> </Inert> <Inert label={t('Redo')} test="header-redo"> - <Icons.RedoOutlined iconSize="m" /> + <Icons.RedoOutlined iconSize="s" /> </Inert> <Inert label={t('Save')} test="header-save"> {t('Save')} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx index fac397a2d61d..7f9a20a3fcf7 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx @@ -120,6 +120,15 @@ export default function LayoutModeSwitcher({ <Radio.Button value={option.key} data-test={`layout-mode-${option.key}`} + // Sized with the rest of the header rather than left at antd's + // small step: a control that stands taller than everything + // beside it reads as a different kind of thing. + style={{ + height: theme.controlHeightSM, + paddingInline: theme.sizeUnit * 2, + fontSize: theme.fontSizeSM, + lineHeight: `${theme.controlHeightSM - 2}px`, + }} > {option.icon} {option.label} </Radio.Button> From 5c8b3d2e313eef2934036a48e9baa54d14da31c5 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 09:38:33 +0200 Subject: [PATCH 08/19] feat(dashboard-v2): make the palette drag, and let a block be removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, one of them a defect this shell introduced. The palette rows carried a drag grip beside every label and no drag. They are draggable now, onto any container rather than only the root: a nested section is exactly where an author means to put something when they drag it there, and the innermost container under the pointer takes the drop rather than every ancestor claiming it. The payload is a private type, so a dragged file or a selection of text from another window is not read as a request to place a block. Both ways of asking now go through one `placeBlock`. 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 stays invisible until someone hits it. Blocks gained a remove control in their top-right corner, shown while the block is hovered or selected rather than permanently — a delete on every block at all times is a row of delete buttons where a dashboard should be. It is excluded from the grid's drag via `draggableCancel`, because react-grid-layout starts a drag on a press anywhere in the block it positions and aiming at the X would otherwise drag what it is attached to. The root has none: removing it is refused by the provider, so offering the button would be offering an error. The palette's search field is also set down from the tab bar and in from the panel edge, where flush against both it read as chrome around the list rather than the way into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/core/dashboard/BuildingBlockView.tsx | 70 ++++++++++++ .../dashboard/blocks/CanvasBlock.test.tsx | 104 ++++++++++++++++++ .../src/core/dashboard/blocks/CanvasBlock.tsx | 23 +++- .../src/core/dashboard/blocks/FlexCanvas.tsx | 18 +++ .../src/core/dashboard/placement.ts | 71 ++++++++++++ .../DashboardBuilderV2/EditorPanel.test.tsx | 27 +++++ .../src/pages/DashboardBuilderV2/Palette.tsx | 23 +++- .../src/pages/DashboardBuilderV2/index.tsx | 27 ++--- 8 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 superset-frontend/src/core/dashboard/placement.ts diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index 2a22d4884e06..28dc2c0a4ae7 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -20,6 +20,7 @@ import { forwardRef, type HTMLAttributes } from 'react'; import { t } from '@apache-superset/core/translation'; import { useTheme } from '@apache-superset/core/theme'; import { 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'; @@ -92,6 +93,9 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( const resolved = resolveBuildingBlockView(node.type, nodeId); const selected = provider.getSelection() === nodeId; + // The root holds the dashboard; removing it is refused by the provider, + // so offering the button would be offering a error. + const removable = nodeId !== provider.getRoot().id; return ( <div @@ -128,12 +132,78 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( }} style={{ ...rest.style, + // The remove control anchors to this element. react-grid-layout + // positions its children itself, so its own value is kept wherever + // it set one and `relative` only fills the gap when it did not. + position: rest.style?.position ?? 'relative', // Drawn over the block rather than around it: an outline takes no // space, so nothing on screen shifts when a selection moves. outline: selected ? `2px solid ${theme.colorPrimary}` : undefined, outlineOffset: selected ? -2 : undefined, }} > + {/* Removing a block, where the block 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. + + `data-block-remove` is what keeps a press on it from starting a + react-grid-layout drag; see CanvasBlock's `draggableCancel`. The + propagation stops are the same idea for the two gestures this + element sits inside: a click here removes rather than selects, + and a pointer down here grabs nothing. */} + {removable && ( + <button + type="button" + data-block-remove + data-test={`block-remove-${nodeId}`} + aria-label={t('Remove block')} + title={t('Remove block')} + onMouseDown={event => event.stopPropagation()} + onPointerDown={event => event.stopPropagation()} + onClick={event => { + event.stopPropagation(); + provider.removeBuildingBlock(nodeId); + }} + style={{ + position: 'absolute', + top: theme.sizeUnit, + right: theme.sizeUnit, + zIndex: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: theme.sizeUnit * 5, + height: theme.sizeUnit * 5, + padding: 0, + border: `1px solid ${theme.colorBorder}`, + borderRadius: theme.borderRadius, + background: theme.colorBgContainer, + color: theme.colorTextTertiary, + cursor: 'pointer', + // Shown once the block is in hand — hovered or selected — rather + // than permanently: a delete on every block at all times is a + // row of delete buttons where a dashboard should be. + opacity: selected ? 1 : 0, + }} + onFocus={event => { + event.currentTarget.style.opacity = '1'; + }} + onBlur={event => { + event.currentTarget.style.opacity = selected ? '1' : '0'; + }} + onMouseEnter={event => { + event.currentTarget.style.opacity = '1'; + }} + onMouseLeave={event => { + event.currentTarget.style.opacity = selected ? '1' : '0'; + }} + > + <Icons.CloseOutlined iconSize="s" /> + </button> + )} <div style={{ width: '100%', height: '100%' }}> <ErrorBoundary> {resolved ?? <UnsupportedBlockPlaceholder nodeId={nodeId} />} diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx index 660f36107d3e..1408b0fc60c6 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx @@ -36,15 +36,18 @@ jest.mock('react-grid-layout/legacy', () => ({ children, compactType, allowOverlap, + draggableCancel, }: { children: React.ReactNode; compactType: string | null; allowOverlap?: boolean; + draggableCancel?: string; }) => ( <div data-test="rgl" data-compact-type={String(compactType)} data-allow-overlap={String(!!allowOverlap)} + data-draggable-cancel={draggableCancel ?? ''} > {children} </div> @@ -145,3 +148,104 @@ test('a flex child dropped on itself changes nothing', () => { expect(provider.getNode(rootId)?.children).toEqual([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('dropping a palette block on a container places it there', () => { + const { rootId } = withMode('grid'); + + fireEvent.drop(screen.getByTestId('canvas-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 into a flex container lands there too', () => { + const { rootId } = withMode('flex'); + + fireEvent.drop(screen.getByTestId('flex-canvas'), { + dataTransfer: paletteTransfer('echarts'), + }); + + const children = provider.getNode(rootId)?.children ?? []; + expect(provider.getNode(children[children.length - 1])?.type).toBe('echarts'); +}); + +test('a drop carrying something else is not read as a block', () => { + const { rootId } = withMode('grid'); + const before = provider.getNode(rootId)?.children?.length; + + fireEvent.drop(screen.getByTestId('canvas-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 } = withMode('grid'); + + 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 } = withMode('grid'); + + fireEvent.click(screen.getByTestId(`block-remove-${first}`)); + + expect(provider.getNode(rootId)?.children).toEqual([second]); +}); + +test('the grid is told not to start a drag from the remove control', () => { + const { first } = withMode('grid'); + + // react-grid-layout begins a drag on a press anywhere in the block it is + // positioning, and the button sits inside that block. `draggableCancel` is + // what it reads to exclude a region, so the selector and the attribute the + // button carries have to agree — aiming at the X would otherwise drag the + // block it is attached to. + const cancel = screen + .getByTestId('rgl') + .getAttribute('data-draggable-cancel'); + expect(cancel).toContain('[data-block-remove]'); + expect(screen.getByTestId(`block-remove-${first}`)).toHaveAttribute( + 'data-block-remove', + ); +}); + +test('clicking the remove control removes rather than selects', () => { + const { first, second } = withMode('grid'); + + 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(); +}); diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx index 3ab27e20dcf8..1dfc1d7142e0 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx @@ -29,6 +29,7 @@ import { useTheme } from '@apache-superset/core/theme'; import { provider, useDashboardRevision } from '../store'; import { resolveGridMetrics, resolveLayoutMode } from '../layoutStyle'; import { packChildLayout } from '../gridPacking'; +import { PALETTE_MIME, placeBlock } from '../placement'; import BuildingBlockView from '../BuildingBlockView'; import FlexCanvas from './FlexCanvas'; @@ -239,6 +240,26 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { return ( <div data-container-id={nodeId} + data-test="canvas-container" + // Every container is a drop target, not just the root: a nested + // section is exactly where an author means to put something when they + // drag it there, and the stop is what makes the innermost container + // under the pointer the one that takes it rather than every ancestor + // claiming the same drop. + onDragOver={event => { + if (event.dataTransfer.types.includes(PALETTE_MIME)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } + }} + onDrop={event => { + const type = event.dataTransfer.getData(PALETTE_MIME); + if (type !== '') { + event.preventDefault(); + event.stopPropagation(); + placeBlock(nodeId, type); + } + }} style={{ width: '100%', height: '100%', overflow: 'auto' }} > <ResizableGridLayout @@ -286,7 +307,7 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { // `data-container-id` itself, so this never affects dragging a // leaf — only a nested canvas becomes un-draggable as a whole via // a body click (it's still resizable via its own corner handles). - draggableCancel="[data-container-id]" + draggableCancel="[data-container-id],[data-block-remove]" onDragStart={handleDragStart} onDragStop={handleDragStop} onResizeStart={handleResizeStart} diff --git a/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx index 356be5dd7897..a71c76a45783 100644 --- a/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx +++ b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx @@ -26,6 +26,7 @@ import { resolveFlexBasis, resolveFlexMetrics, } from '../layoutStyle'; +import { PALETTE_MIME, placeBlock } from '../placement'; import BuildingBlockView from '../BuildingBlockView'; type LayoutProps = dashboardApi.LayoutProps; @@ -91,6 +92,23 @@ export default function FlexCanvas({ <div data-container-id={nodeId} data-test="flex-canvas" + // A flex container takes a palette drop like every other container. Its + // own children carry a different payload, so a reorder within the line + // and a placement from the palette never read as each other. + onDragOver={event => { + if (event.dataTransfer.types.includes(PALETTE_MIME)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + } + }} + onDrop={event => { + const type = event.dataTransfer.getData(PALETTE_MIME); + if (type !== '') { + event.preventDefault(); + event.stopPropagation(); + placeBlock(nodeId, type); + } + }} style={{ display: 'flex', width: '100%', diff --git a/superset-frontend/src/core/dashboard/placement.ts b/superset-frontend/src/core/dashboard/placement.ts new file mode 100644 index 000000000000..80830d06b969 --- /dev/null +++ b/superset-frontend/src/core/dashboard/placement.ts @@ -0,0 +1,71 @@ +/** + * 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 { 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'; + +/** + * 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. + */ +export function placeBlock(parentId: string, type: string): string { + const index = provider.getNode(parentId)?.children?.length ?? 0; + const id = provider.addBuildingBlock(parentId, index, { + type, + ...(isContainerType(type) + ? { + layout: { + columns: DEFAULT_COLUMNS, + gap: 16, + colSpan: DEFAULT_COLUMNS, + rowSpan: 4, + }, + } + : {}), + }); + provider.setSelection(id); + return id; +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx index 5260047624fd..b5fdce76afb6 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx @@ -191,3 +191,30 @@ test('the handle reports the width it actually has', () => { '500', ); }); + +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', + ); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx index 91e5d4e90393..e9ccbf945763 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx @@ -25,6 +25,7 @@ import { Icons } from '@superset-ui/core/components/Icons'; import { views } 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'; /** * Which shelf a block sits on. @@ -160,7 +161,15 @@ export default function Palette({ return ( <div data-test="palette" - style={{ display: 'flex', flexDirection: 'column', gap: theme.sizeUnit }} + style={{ + display: 'flex', + flexDirection: 'column', + gap: theme.sizeUnit, + // 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`, + }} > <Input size="small" @@ -170,6 +179,7 @@ export default function Palette({ placeholder={t('Search components…')} data-test="palette-search" prefix={<Icons.SearchOutlined iconSize="s" />} + style={{ marginBottom: theme.sizeUnit }} onChange={event => setQuery(event.target.value)} /> {found.length === 0 ? ( @@ -202,9 +212,18 @@ export default function Palette({ <button 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 rows 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'; + }} style={{ display: 'flex', alignItems: 'center', @@ -219,7 +238,7 @@ export default function Palette({ background: theme.colorBgContainer, color: theme.colorText, fontSize: theme.fontSizeSM, - cursor: 'pointer', + cursor: 'grab', }} > {entry.label} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 25531723818c..90eaa94e38cf 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -23,7 +23,7 @@ import { Flex, Typography } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { dashboard, useDashboardRevision } from 'src/core/dashboard'; import { provider } from 'src/core/dashboard/store'; -import { isContainerType } from 'src/core/dashboard/DashboardProvider'; +import { placeBlock } from 'src/core/dashboard/placement'; import { chat } from 'src/core/chat'; import BuildingBlockView from 'src/core/dashboard/BuildingBlockView'; import { dashboardClientTools } from './clientTools'; @@ -109,29 +109,22 @@ export default function DashboardBuilderV2() { * * 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. + * 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. * - * The new block is then selected, because placing something is the moment - * you want to configure it — which is also what brings Properties forward. + * 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); - const parentId = - selectedNode?.children !== undefined ? selectedNode.id : root.id; - const index = provider.getNode(parentId)?.children?.length ?? 0; - const id = dashboard.addBuildingBlock(parentId, index, { + placeBlock( + selectedNode?.children !== undefined ? selectedNode.id : root.id, type, - // A container arrives with the grid every other container defaults to, - // so a nested canvas is usable the moment it is placed rather than - // needing its columns set before anything can go in it. - ...(isContainerType(type) - ? { layout: { columns: 24, gap: 16, colSpan: 24, rowSpan: 4 } } - : {}), - }); - provider.setSelection(id); + ); }; return ( From ecda1477e4868930b43e2c99a40c751144e3b84c Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 09:51:04 +0200 Subject: [PATCH 09/19] fix(dashboard-v2): give a flex child the box every block is drawn in Every leaf block fills the box its placement wrapper hands it: a chart measures that box to size its canvas, markdown scrolls inside it. In a grid that box arrives as the explicit pixel width and height react-grid-layout injects when it clones the block, and ChartBlock's own comment already named the hazard -- a measured size that "collapses to zero the way an unconstrained flex height could". FlexCanvas positions its children itself and never handed the box down, so every block was content-height instead. A chart's measured height settled at its loading indicator, drawing a ~30px strip at the top of an otherwise empty cell; markdown taller than its share painted over the row beneath it. The height a flex child reserves also disagreed with the grid's: react-grid-layout counts the gaps *between* the spanned rows toward the block, so the same rowSpan drew shorter in flex and switching mode resized the whole canvas. resolveBlockHeightPx now carries that one formula for both. --- .../dashboard/blocks/CanvasBlock.test.tsx | 48 ++++++++++++++++++- .../src/core/dashboard/blocks/FlexCanvas.tsx | 17 ++++++- .../src/core/dashboard/layoutStyle.ts | 23 +++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx index 1408b0fc60c6..896523bfe6a1 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { fireEvent, render, screen } from 'spec/helpers/testing-library'; +import { + fireEvent, + render, + screen, + within, +} from 'spec/helpers/testing-library'; import DashboardProvider from '../DashboardProvider'; import CanvasBlock from './CanvasBlock'; @@ -112,6 +117,47 @@ test('a flex container is not a grid at all', () => { expect(screen.queryByTestId('rgl')).not.toBeInTheDocument(); }); +test('a flex child hands its block a definite box', () => { + const rootId = provider.getRoot().id; + provider.updateLayout(rootId, { mode: 'flex' }); + const id = provider.addBuildingBlock(rootId, 0, { + type: 'markdown', + layout: { rowSpan: 4 }, + }); + render(<CanvasBlock nodeId={rootId} />); + + // Every leaf block fills the box its placement wrapper gives it — a chart + // measures that box to size its canvas, and markdown scrolls inside it. In + // a grid, react-grid-layout supplies the box by cloning the block with an + // explicit pixel width and height. A flex container positions its own + // children, so it has to hand the same box down itself; without it the + // block is content-height, the chart's measured height collapses, and + // markdown taller than its share paints over the row beneath it. + const block = within(screen.getByTestId(`flex-child-${id}`)).getByRole( + 'button', + { name: 'markdown' }, + ); + expect(block).toHaveStyle({ width: '100%', height: '100%' }); +}); + +test('a flex child is as tall as the same block in a grid', () => { + const rootId = provider.getRoot().id; + provider.updateLayout(rootId, { mode: 'flex' }); + const id = provider.addBuildingBlock(rootId, 0, { + type: 'markdown', + layout: { rowSpan: 4 }, + }); + render(<CanvasBlock nodeId={rootId} />); + + // react-grid-layout reserves the rows *and the gaps between them* + // (`rowUnit * rowSpan + (rowSpan - 1) * gap`), so 4 rows of 32 with a gap + // of 16 is 176px, not 128. Counting only the rows would make every block on + // the canvas shrink the moment the mode changed. + expect(screen.getByTestId(`flex-child-${id}`)).toHaveStyle({ + height: '176px', + }); +}); + test('dragging one flex child onto another reorders them', () => { const { rootId, first, second } = withMode('flex'); const data = new Map<string, string>(); diff --git a/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx index a71c76a45783..ad3fad0e6dbb 100644 --- a/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx +++ b/superset-frontend/src/core/dashboard/blocks/FlexCanvas.tsx @@ -23,6 +23,7 @@ import { useTheme } from '@apache-superset/core/theme'; import { provider } from '../store'; import { DEFAULT_COLUMNS, + resolveBlockHeightPx, resolveFlexBasis, resolveFlexMetrics, } from '../layoutStyle'; @@ -153,7 +154,7 @@ export default function FlexCanvas({ metrics.flexDirection === 'row' ? `calc(${basis} - ${metrics.gap}px)` : undefined, - height: (child?.layout?.rowSpan ?? 1) * metrics.rowUnitPx, + height: resolveBlockHeightPx(child?.layout?.rowSpan, metrics), outline: over === childId ? `2px solid ${theme.colorPrimary}` @@ -161,7 +162,19 @@ export default function FlexCanvas({ cursor: 'grab', }} > - <BuildingBlockView nodeId={childId} /> + {/* The box, handed down. Every leaf block fills the one its + placement wrapper gives it — a chart measures it to size its + canvas, markdown scrolls inside it — and in a grid that box + arrives as the explicit pixel width and height + react-grid-layout injects when it clones the block. A flex + container positions its children itself, so it owes them the + same thing: without it the block is content-height, a chart's + measured height collapses to its loading indicator, and + markdown taller than its share paints over the row below. */} + <BuildingBlockView + nodeId={childId} + style={{ width: '100%', height: '100%' }} + /> </div> ); })} diff --git a/superset-frontend/src/core/dashboard/layoutStyle.ts b/superset-frontend/src/core/dashboard/layoutStyle.ts index ebed3c08c945..603340311ec6 100644 --- a/superset-frontend/src/core/dashboard/layoutStyle.ts +++ b/superset-frontend/src/core/dashboard/layoutStyle.ts @@ -63,6 +63,29 @@ export function resolveGridMetrics( }; } +/** + * The pixel height a container reserves for a child spanning `rowSpan` rows. + * + * The gaps *between* those rows belong to the block, not to the space around + * it: this is `react-grid-layout`'s own `calcGridItemWHPx`, which already + * sizes every grid-mode block. A container that positions its own children + * has to apply the same formula rather than multiplying rows by row height, + * or the identical block is drawn shorter than the grid drew it and changing + * a container's mode silently resizes everything in it. + * + * A child that never declared a span occupies one row, matching the same + * default `packChildLayout` gives it. + */ +export function resolveBlockHeightPx( + rowSpan: number | undefined, + metrics: Pick<GridMetrics, 'gap' | 'rowUnitPx'>, +): number { + const rows = rowSpan ?? 1; + return Math.round( + rows * metrics.rowUnitPx + Math.max(0, rows - 1) * metrics.gap, + ); +} + /** The CSS a `flex` container lays its own children out with. */ export interface FlexMetrics { flexDirection: 'row' | 'column'; From 0bb9705bbc990a313e88b48e07107f249b44c817 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 10:08:23 +0200 Subject: [PATCH 10/19] feat(dashboard-v2): give every block a header that names it A block on the canvas said nothing about which block it was. The one place it was named -- the Outline -- named it by rules of its own, so the same chart could read "Sales by Territory" in the panel and nothing at all on the canvas. blockLabel holds those rules now, in one place both callers can reach: a name the block's own content carries wins (a chart's ECharts title, a metric tile's label, markdown's opening words) and only a block with none falls back to the registered type name. Returned whole, since a row in a panel and a header on a wide chart cut a long name at different points. The header carries that name on the left and the delete control on the right, and the control no longer waits to be hovered: a control you have to already know is there is a control most people never find. The root gets no header -- it is the dashboard rather than something on it, and the provider refuses to remove it. A chart's name is authored in its ECharts option, so ChartBlock stops ECharts drawing it: it belongs where every other block's name is, once, rather than twice at two sizes. The band comes out of the block's own box in pixels off a percentage rather than by making the wrapper a flex column -- what a leaf block does with that box is resolve `height: 100%` against it, and a chart measures the result to size its canvas, so it wants a height there is no question about. --- .../core/dashboard/BuildingBlockView.test.tsx | 91 ++++++++++ .../src/core/dashboard/BuildingBlockView.tsx | 155 +++++++++++------- .../src/core/dashboard/blockLabel.test.ts | 73 +++++++++ .../src/core/dashboard/blockLabel.ts | 83 ++++++++++ .../core/dashboard/blocks/ChartBlock.test.tsx | 90 ++++++++++ .../src/core/dashboard/blocks/ChartBlock.tsx | 27 +-- 6 files changed, 444 insertions(+), 75 deletions(-) create mode 100644 superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx create mode 100644 superset-frontend/src/core/dashboard/blockLabel.test.ts create mode 100644 superset-frontend/src/core/dashboard/blockLabel.ts create mode 100644 superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx new file mode 100644 index 000000000000..92db95974e42 --- /dev/null +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx @@ -0,0 +1,91 @@ +/** + * 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: 'markdown', + props: { content: '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 "Markdown" 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('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('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%', + }); +}); diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index 28dc2c0a4ae7..c9fb7b003bf9 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -24,6 +24,7 @@ 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'; function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) { const theme = useTheme(); @@ -93,14 +94,22 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( const resolved = resolveBuildingBlockView(node.type, nodeId); const selected = provider.getSelection() === nodeId; - // The root holds the dashboard; removing it is refused by the provider, - // so offering the button would be offering a error. - const removable = nodeId !== provider.getRoot().id; + // The root is the dashboard itself rather than something on it: it has no + // name of its own to show, and removing it is refused by the provider, so + // a header there would be a label saying "Canvas" over a button that only + // ever raises an error. + const chrome = nodeId !== provider.getRoot().id; + 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 @@ -132,9 +141,10 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( }} style={{ ...rest.style, - // The remove control anchors to this element. react-grid-layout - // positions its children itself, so its own value is kept wherever - // it set one and `relative` only fills the gap when it did not. + // A block's contents are positioned against this element. + // react-grid-layout positions its children itself, so its own value + // is kept wherever it set one and `relative` only fills the gap + // when it did not. position: rest.style?.position ?? 'relative', // Drawn over the block rather than around it: an outline takes no // space, so nothing on screen shifts when a selection moves. @@ -142,69 +152,90 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( outlineOffset: selected ? -2 : undefined, }} > - {/* Removing a block, where the block 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. + {/* 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 it from starting a - react-grid-layout drag; see CanvasBlock's `draggableCancel`. The - propagation stops are the same idea for the two gestures this - element sits inside: a click here removes rather than selects, - and a pointer down here grabs nothing. */} - {removable && ( - <button - type="button" - data-block-remove - data-test={`block-remove-${nodeId}`} - aria-label={t('Remove block')} - title={t('Remove block')} - onMouseDown={event => event.stopPropagation()} - onPointerDown={event => event.stopPropagation()} - onClick={event => { - event.stopPropagation(); - provider.removeBuildingBlock(nodeId); - }} + `data-block-remove` is what keeps a press on the button from + starting a react-grid-layout drag; see CanvasBlock's + `draggableCancel`. The propagation stops are the same idea for the + two gestures it sits inside: a click here removes rather than + selects, and a pointer down here grabs nothing. + + 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 && ( + <div + data-test={`block-header-${nodeId}`} style={{ - position: 'absolute', - top: theme.sizeUnit, - right: theme.sizeUnit, - zIndex: 2, display: 'flex', alignItems: 'center', - justifyContent: 'center', - width: theme.sizeUnit * 5, - height: theme.sizeUnit * 5, - padding: 0, - border: `1px solid ${theme.colorBorder}`, - borderRadius: theme.borderRadius, - background: theme.colorBgContainer, - color: theme.colorTextTertiary, - cursor: 'pointer', - // Shown once the block is in hand — hovered or selected — rather - // than permanently: a delete on every block at all times is a - // row of delete buttons where a dashboard should be. - opacity: selected ? 1 : 0, - }} - onFocus={event => { - event.currentTarget.style.opacity = '1'; - }} - onBlur={event => { - event.currentTarget.style.opacity = selected ? '1' : '0'; - }} - onMouseEnter={event => { - event.currentTarget.style.opacity = '1'; - }} - onMouseLeave={event => { - event.currentTarget.style.opacity = selected ? '1' : '0'; + gap: theme.sizeUnit, + height: headerHeight, + paddingLeft: theme.sizeUnit, + paddingRight: theme.sizeUnit, }} > - <Icons.CloseOutlined iconSize="s" /> - </button> + <Typography.Text + ellipsis + data-test={`block-title-${nodeId}`} + style={{ + flex: '1 1 auto', + fontSize: theme.fontSizeSM, + color: theme.colorTextSecondary, + }} + > + {blockLabel(node.type, node.props)} + </Typography.Text> + <button + type="button" + data-block-remove + data-test={`block-remove-${nodeId}`} + aria-label={t('Remove block')} + title={t('Remove block')} + onMouseDown={event => event.stopPropagation()} + onPointerDown={event => event.stopPropagation()} + onClick={event => { + event.stopPropagation(); + provider.removeBuildingBlock(nodeId); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flex: '0 0 auto', + width: headerHeight, + height: headerHeight, + padding: 0, + border: 'none', + background: 'none', + color: theme.colorTextTertiary, + cursor: 'pointer', + }} + > + <Icons.CloseOutlined iconSize="s" /> + </button> + </div> )} - <div style={{ width: '100%', height: '100%' }}> + {/* 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> diff --git a/superset-frontend/src/core/dashboard/blockLabel.test.ts b/superset-frontend/src/core/dashboard/blockLabel.test.ts new file mode 100644 index 000000000000..2b7b74f8e38a --- /dev/null +++ b/superset-frontend/src/core/dashboard/blockLabel.test.ts @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +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('markdown is named by its opening words', () => { + expect( + blockLabel('markdown', { content: '# Acme Corp\n\nGenerated November' }), + ).toBe('# Acme Corp Generated November'); +}); + +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'); + expect(blockLabel('markdown', { content: '\n\n' })).toBe('Markdown'); +}); + +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'); +}); diff --git a/superset-frontend/src/core/dashboard/blockLabel.ts b/superset-frontend/src/core/dashboard/blockLabel.ts new file mode 100644 index 000000000000..1eea5ea1adf7 --- /dev/null +++ b/superset-frontend/src/core/dashboard/blockLabel.ts @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @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. + * + * Only the types that have one: everything else is named by its registration, + * and an unlisted type — an extension's — falls through to that without + * needing to be known here. + */ +const NAMED_BY: Record<string, (props: Props) => unknown> = { + markdown: props => props?.content, + echarts: echartsTitle, + 'metric-tile': props => props?.label, +}; + +/** + * What to call the block of `type` holding `props`. + * + * A name the block's own content carries wins, because that is the name its + * author gave it and the one they will look for. Only when there is none does + * this fall back to the registered block name — "Markdown", "Table" — which + * says what a block is rather than which one it is, and is worth nothing at + * all when five of them sit in a column. + * + * 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 { + 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; +} diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx new file mode 100644 index 000000000000..9d150946fe64 --- /dev/null +++ b/superset-frontend/src/core/dashboard/blocks/ChartBlock.test.tsx @@ -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'); +}); diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx index 3225e0a1f204..6d2562764f9d 100644 --- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx @@ -223,19 +223,20 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [bindingKey]); - const option = useMemo( - () => - rows - ? resolveBindings( - (node?.props?.echartsOptions as Record<string, unknown>) ?? {}, - { - rows, - theme, - }, - ) - : undefined, - [node?.props?.echartsOptions, rows, theme], - ); + 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; From b30ed4e2aa4868428a7870af371c7852fd4a9b36 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:05:04 +0200 Subject: [PATCH 11/19] feat(dashboard-v2): scroll the outline to the block it selects 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 not currently offering -- something scrolled past, or nested inside a section further down -- and clicking one of those left an author looking at an unchanged canvas, with nothing to say the click had landed. The block's own element already carries `data-node-id`, so the canvas needs no wiring back to here: the outline finds the element and scrolls it into view itself. `nearest` rather than `center`, because this fires on every row and reading down a list of blocks that are already in view should not drag the canvas under them. A node can be in the tree without being rendered, and nothing happens at all when the element is absent -- the selection has been set by then either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../pages/DashboardBuilderV2/Outline.test.tsx | 122 ++++++++++++++++++ .../src/pages/DashboardBuilderV2/Outline.tsx | 27 +++- 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx new file mode 100644 index 000000000000..78581a888cbe --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.test.tsx @@ -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([]); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx index 857157d37032..609d83228f37 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx @@ -46,6 +46,29 @@ const labelOf = (type: string, props: Record<string, unknown> | undefined) => { 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' }); +}; + const Row = ({ nodeId, depth, @@ -69,11 +92,11 @@ const Row = ({ aria-selected={selected} tabIndex={selected ? 0 : -1} data-test={`outline-row-${nodeId}`} - onClick={() => provider.setSelection(nodeId)} + onClick={() => select(nodeId)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - provider.setSelection(nodeId); + select(nodeId); } }} style={{ From 03873de04656e5ffc7987fa962df5e6c02ad1ecf Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:05:38 +0200 Subject: [PATCH 12/19] feat(dashboard-v2): let a block be put in front of the one it overlaps A free canvas is the one mode where blocks can overlap, and which of two overlapping blocks won was not something an author could see, let alone choose. react-grid-layout gives an overlapping child no `z-index` of its own, so the browser fell back to tree order and the container's `children` order silently became the paint order: a block earlier in the array could not be brought forward by moving it, resizing it, or selecting it. Dragging one over another appeared to work, because react-grid-layout raises whatever is being dragged -- and then dropped it back underneath. `bringToFront` and `sendToBack` say it directly, and a drag that ends in a free canvas says it too: releasing a block over another is the gesture that means "in front", so `handleDragStop` raises what was dropped. The two ends are the whole control on purpose. "Forward one" and "back one" are the same call with an index arithmetic that only means anything to someone already picturing the array. Reordering a block used to move it: `moveBuildingBlock` reset `col`, `row` and `colSpan` on every call, which is right when a block changes parent and lands in a grid it has no coordinates in, and wrong when it stays where it is. It is now guarded on the parent actually changing, so raising a block leaves it where the author put it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../core/dashboard/DashboardProvider.test.ts | 84 ++++++++++++++++++ .../src/core/dashboard/DashboardProvider.ts | 87 ++++++++++++++++--- .../src/core/dashboard/blocks/CanvasBlock.tsx | 16 +++- 3 files changed, 172 insertions(+), 15 deletions(-) diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts index 63f0bc5c6cc5..313add0e7182 100644 --- a/superset-frontend/src/core/dashboard/DashboardProvider.test.ts +++ b/superset-frontend/src/core/dashboard/DashboardProvider.test.ts @@ -140,6 +140,90 @@ test('moveBuildingBlock relocates a node to a new parent at the given index', () expect(provider.getNode(canvasId)?.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 }); + + // Reordering within one parent is how a free canvas expresses "bring this + // to the front" — 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('bringToFront makes a node the last of its siblings, where it is drawn over them', () => { + const provider = DashboardProvider.getInstance(); + const rootId = provider.getRoot().id; + const first = provider.addBuildingBlock(rootId, 0, { type: 'text' }); + const second = provider.addBuildingBlock(rootId, 1, { type: 'text' }); + const third = provider.addBuildingBlock(rootId, 2, { type: 'text' }); + provider.updateLayout(first, { col: 3, row: 2 }); + + provider.bringToFront(first); + + expect(provider.getRoot().children).toEqual([second, third, first]); + // Raising a block is not moving it. + expect(provider.getNode(first)?.layout).toMatchObject({ col: 3, row: 2 }); +}); + +test('sendToBack makes a node the first of its siblings', () => { + 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.sendToBack(second); + + expect(provider.getRoot().children).toEqual([second, first]); +}); + +test('raising a node already in front changes nothing, and says nothing changed', () => { + const provider = DashboardProvider.getInstance(); + const rootId = provider.getRoot().id; + provider.addBuildingBlock(rootId, 0, { type: 'text' }); + const last = provider.addBuildingBlock(rootId, 1, { type: 'text' }); + const revisionBefore = provider.getRevision(); + + provider.bringToFront(last); + + // A drag that ends on the block already in front is the common case, and + // a revision tick for it re-renders every subscriber for nothing. + expect(provider.getRevision()).toBe(revisionBefore); +}); + +test('bringToFront is a no-op for the root, which has no siblings', () => { + const provider = DashboardProvider.getInstance(); + const revisionBefore = provider.getRevision(); + + expect(() => provider.bringToFront(provider.getRoot().id)).not.toThrow(); + expect(provider.getRevision()).toBe(revisionBefore); +}); + +test('getParentId returns the canvas holding a node', () => { + const provider = DashboardProvider.getInstance(); + const rootId = provider.getRoot().id; + const canvasId = provider.addBuildingBlock(rootId, 0, { type: 'canvas' }); + const childId = provider.addBuildingBlock(canvasId, 0, { type: 'text' }); + + expect(provider.getParentId(childId)).toBe(canvasId); + expect(provider.getParentId(canvasId)).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; diff --git a/superset-frontend/src/core/dashboard/DashboardProvider.ts b/superset-frontend/src/core/dashboard/DashboardProvider.ts index f3d8a9c0e83e..e7475c485b90 100644 --- a/superset-frontend/src/core/dashboard/DashboardProvider.ts +++ b/superset-frontend/src/core/dashboard/DashboardProvider.ts @@ -156,6 +156,19 @@ class DashboardProvider { public getNode = (id: string): DashboardNode | undefined => this.toNode(id); + /** + * The canvas a node sits in, or `undefined` for the root and for a node + * that is not in the tree. + * + * {@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; @@ -299,6 +312,8 @@ class DashboardProvider { ); } + const oldParentId = this.findParentId(id, this.nodes); + const nodes = { ...this.nodes }; Object.entries(nodes).forEach(([parentId, parent]) => { if (parent.children?.includes(id)) { @@ -322,24 +337,68 @@ class DashboardProvider { // drag-based reparenting (see `CanvasBlock`'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. - 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, - }, - }; + // + // None of which is true when the parent has not changed. A move within + // one container is a reorder — how a free canvas says "put this in + // front", since paint order there is child order — 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 raising it. + 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); } + /** + * Which of its siblings a node is drawn over. + * + * Where children overlap — a `free` canvas — the container's child order is + * the paint order, because `react-grid-layout` gives an overlapping item no + * `z-index` of its own and the browser falls back to tree order. So "in + * front" is "last", and raising a block is reordering it. + * + * Named for what an author means rather than left to callers to express as + * an index, because the index is a trap: {@link moveBuildingBlock} detaches + * before it inserts, so the array a node lands in is one shorter than the + * one that was counted. Every caller getting that arithmetic right + * separately is a bug waiting for the second caller. + * + * Doing nothing when there is nothing to do is part of the contract, not an + * optimisation. A drag ends on the block already in front more often than + * not, and a revision tick there re-renders every subscriber to produce the + * array it already had. + */ + private restack(id: string, edge: 'front' | 'back'): void { + const parentId = this.findParentId(id, this.nodes); + if (parentId === undefined) return; + const children = this.nodes[parentId]?.children ?? []; + const settled = edge === 'front' ? children.at(-1) : children[0]; + if (settled === id) return; + this.moveBuildingBlock( + id, + parentId, + edge === 'front' ? children.length : 0, + ); + } + + public bringToFront = (id: string): void => this.restack(id, 'front'); + + public sendToBack = (id: string): void => this.restack(id, 'back'); + public updateLayout(id: string, layout: Partial<LayoutProps>): void { const node = this.nodes[id]; if (!node) { diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx index 1dfc1d7142e0..21fbe8cfe1fd 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx @@ -199,8 +199,22 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { } commitLayout(rglLayout); + + // A block dragged over its siblings is a block meant to be in front of + // them, and on a free canvas that has to be said in the document. + // `react-grid-layout` floats the dragged item with a `z-index: 3` it + // carries on a class while the pointer is down and loses on release — + // so the gesture shows the block on top the whole way, then drops it + // behind whatever it was dragged over. The lift was only ever CSS. + // + // Only where children overlap. Every other mode arranges them so they + // cannot, and reordering there would rewrite reading order, tab order + // and the Outline to change nothing anyone can see. + if (newItem && resolveLayoutMode(node?.layout) === 'free') { + provider.bringToFront(newItem.i); + } }, - [nodeId, commitLayout], + [nodeId, node?.layout, commitLayout], ); if (!node) return null; From 0a24a9c1b6560b14749bfbf213730426ca92916b Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:06:09 +0200 Subject: [PATCH 13/19] style(dashboard-v2): draw one frame around a block, with its name inside The card -- background, border, rounded corners -- was drawn by each leaf block, and a leaf begins below the header. So a block's top edge ran between its name and its contents: the name sat outside the box it names and read as a caption dropped over a separate card, with a seam across the block a hand's width below the top. Drawn from the wrapper instead, it encloses both. That is also the only place it can be drawn from: whether a node has a header at all is `BuildingBlockView`'s to know, not the leaf's, which is why four blocks each opened with the same three lines. The header stops painting a surface of its own, and no rule is added under it -- one unbroken card with a name on it is how a chart card reads everywhere else. It matters beyond the seam. On a free canvas blocks overlap, and anything a block does not paint is a window onto whatever is behind it, so a block raised to the front still showed the one behind through its own header band. `overflow: hidden` keeps square content out of the corners the frame rounds -- which also stops a block painting outside the cell it was given, something nothing was clipping before. The root keeps a frame and gains a gutter but no surface: it is drawn by a grid that fills its box edge to edge, so it had no pixels of its own, and the inset is what an author aims at to select the dashboard rather than something on it. It carries no background because it is what everything else is arranged on, not a card among them. The name itself is drawn as a title rather than as a note about one. At the small size in the secondary colour it read as an annotation hanging above the block, and it is the first thing anyone scanning a canvas uses to tell one block from the next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../core/dashboard/BuildingBlockView.test.tsx | 64 +++++++++++++++++++ .../src/core/dashboard/BuildingBlockView.tsx | 54 +++++++++++++++- .../dashboard/blocks/AgGridTableBlock.tsx | 6 +- .../src/core/dashboard/blocks/ChartBlock.tsx | 6 +- .../core/dashboard/blocks/MarkdownBlock.tsx | 6 +- .../core/dashboard/blocks/MetricTileBlock.tsx | 6 +- 6 files changed, 127 insertions(+), 15 deletions(-) diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx index 92db95974e42..6f0bd565a7cb 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx @@ -73,6 +73,70 @@ test('the root carries no header of its own', () => { ).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(); diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index c9fb7b003bf9..24e4bf1c3567 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -99,6 +99,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( // a header there would be a label saying "Canvas" over a button that only // ever raises an error. const chrome = nodeId !== provider.getRoot().id; + const isRoot = !chrome; const headerHeight = theme.controlHeightSM; return ( @@ -146,6 +147,37 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( // is kept wherever it set one and `relative` only fills the gap // when it did not. position: rest.style?.position ?? 'relative', + // 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. + backgroundColor: isRoot ? undefined : theme.colorBgContainer, + border: `1px solid ${theme.colorBorderSecondary}`, + borderRadius: theme.borderRadiusLG, + // Nothing reaches past the corners this rounds — a block's content + // is square and would otherwise fill them back in. + overflow: isRoot ? undefined : 'hidden', + // The dashboard's own gutter, and what makes it clickable. + // + // The root is drawn by a grid that fills its box edge to edge + // (`containerPadding={[0, 0]}` in CanvasBlock), which left the + // dashboard with no pixels of its own. The inset is what an author + // aims at to select the dashboard rather than something on it — and + // it is the same inset a block's content sits at, so the two read as + // one scale rather than two. It carries no surface of its own: this + // is what everything else is arranged *on*, not a card among them. + padding: isRoot ? theme.padding : undefined, // Drawn over the block rather than around it: an outline takes no // space, so nothing on screen shifts when a selection moves. outline: selected ? `2px solid ${theme.colorPrimary}` : undefined, @@ -178,8 +210,18 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( alignItems: 'center', gap: theme.sizeUnit, height: headerHeight, - paddingLeft: theme.sizeUnit, + // Aligned with the inset every block's own content sits at, so + // a block's name reads as the head of the box beneath it rather + // than as something floating loose to its left. The right side + // stays tight: the remove button is a square target of its own + // and centres its icon, which is the inset it needs. + paddingLeft: theme.padding, paddingRight: theme.sizeUnit, + // 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. }} > <Typography.Text @@ -187,8 +229,14 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( data-test={`block-title-${nodeId}`} style={{ flex: '1 1 auto', - fontSize: theme.fontSizeSM, - color: theme.colorTextSecondary, + // 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)} diff --git a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx index e3616e0da4b0..9b1b6f8d039c 100644 --- a/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/AgGridTableBlock.tsx @@ -105,9 +105,9 @@ export default function AgGridTableBlock({ nodeId }: { nodeId: string }) { // block — always a definite pixel box, same as `ChartBlock`. width: '100%', height: '100%', - backgroundColor: theme.colorBgContainer, - border: `1px solid ${theme.colorBorderSecondary}`, - borderRadius: theme.borderRadiusLG, + // 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', }} > diff --git a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx index 6d2562764f9d..b54007936da2 100644 --- a/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/ChartBlock.tsx @@ -250,9 +250,9 @@ export default function ChartBlock({ nodeId }: { nodeId: string }) { // so this is never zero or ambiguous. width: '100%', height: '100%', - backgroundColor: theme.colorBgContainer, - border: `1px solid ${theme.colorBorderSecondary}`, - borderRadius: theme.borderRadiusLG, + // 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', }} > diff --git a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx index bb5317cf87b0..0a6c39105181 100644 --- a/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/MarkdownBlock.tsx @@ -37,9 +37,9 @@ export default function MarkdownBlock({ nodeId }: { nodeId: string }) { style={{ width: '100%', height: '100%', - backgroundColor: theme.colorBgContainer, - border: `1px solid ${theme.colorBorderSecondary}`, - borderRadius: theme.borderRadiusLG, + // Surface, border and corners belong to the card `BuildingBlockView` + // draws around this block and the name above it, so that the name is + // inside the frame rather than over it. padding: theme.padding, overflow: 'auto', }} diff --git a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx b/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx index 2bb278dc2253..e5a45c6ac95f 100644 --- a/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/MetricTileBlock.tsx @@ -153,9 +153,9 @@ export default function MetricTileBlock({ nodeId }: { nodeId: string }) { // block — always a definite pixel box, same as `ChartBlock`. width: '100%', height: '100%', - backgroundColor: theme.colorBgContainer, - border: `1px solid ${theme.colorBorderSecondary}`, - borderRadius: theme.borderRadiusLG, + // Surface, border and corners belong to the card `BuildingBlockView` + // draws around this block and the name above it, so that the name is + // inside the frame rather than over it. padding: theme.padding, overflow: 'hidden', }} From 77602612317392c6f629829532431e6cf6ae7382 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:06:51 +0200 Subject: [PATCH 14/19] feat(dashboard-v2): ask the dashboard for the properties a dashboard has Selecting the root offered the same three questions a block gets -- what it renders, where it sits -- none of which the dashboard has an answer to. What it does have is what it is called, who may see it, how it looks, how often it refreshes; and those were reachable only from the saved dashboard's properties modal, which this builder has no route to. The panel now reuses that modal's six sections whole rather than restating them, so the two are ways into one set of fields. Values live on the root's props and commit when focus leaves the panel. Arranging is the one thing a dashboard and a container have in common, so the layout switcher moves here from the header, alongside the columns, gap and row height it works with -- properties of the container they are asked about, rather than a control on a bar above. Which of those are shown now follows the mode, because a field the renderer ignores is worse than a missing one: it accepts a value, writes it to the node, and changes nothing, so an author concludes the layout is broken rather than that the question did not apply. `col` and `row` are grid coordinates and a flex line has no cells to hold them, so a flex child is not asked. And `direction`, `wrap`, `justify` and `align` -- documented on LayoutProps as flex-only and until now offered nowhere at all -- are asked of a flex container, which could otherwise be chosen and then not actually arranged. Properties gains a form beside the JSON. No block type declares a schema, so `inferPropsSchema` reads one off the values the block is holding; a schema shipped with each registration would be better, and this is what stands in until there is one -- a contributed block gets a form on the same terms a built-in one does, with no list to keep current. The two halves divide cleanly: JSON decides the shape, since it alone can add or drop a key, and the form fills in the values. JSON is what the panel opens on, because a block placed a moment ago has no properties and so no fields. The form must not sit under an antd Form: its controls render `Form.Item name=...`, and a Form above them binds those items to its own store, so the field accepts typing and the edit lands somewhere nothing reads. It writes on change rather than on blur for a related reason -- JsonForms debounces what it reports by 10ms, and a blur fires before that lands, saving the value as it stood a keystroke earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../DashboardProperties.tsx | 428 +++++++++++++++++ .../DashboardBuilderV2/Inspector.test.tsx | 255 +++++++++- .../pages/DashboardBuilderV2/Inspector.tsx | 449 +++++++++++++++--- .../pages/DashboardBuilderV2/PropsForm.tsx | 133 ++++++ .../inferPropsSchema.test.ts | 102 ++++ .../DashboardBuilderV2/inferPropsSchema.ts | 88 ++++ 6 files changed, 1397 insertions(+), 58 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx new file mode 100644 index 000000000000..d6fd3359d33b --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx @@ -0,0 +1,428 @@ +/** + * 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 ticks go with it — 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.fontSizeSM, color: theme.colorTextSecondary }}> + {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> + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx index 3838126e8e91..060faf87d252 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx @@ -17,7 +17,12 @@ * under the License. */ import userEvent from '@testing-library/user-event'; -import { fireEvent, render, screen } from 'spec/helpers/testing-library'; +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'; @@ -126,6 +131,83 @@ test('properties that are not an object are refused', () => { expect(screen.getByTestId('inspector-props-apply')).toBeDisabled(); }); +/** Brings the generated form forward; the panel opens on the JSON half. */ +const openForm = async () => { + await userEvent.click(screen.getByRole('tab', { name: 'Form' })); + return 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. JSON is + // where the shape is changed — a key added or dropped — and the form is + // where the values in that shape are filled in. JSON is what it opens on: + // a block placed a moment ago has no properties, and so no fields. + expect(screen.getByRole('tab', { name: 'JSON' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByTestId('inspector-props')).toBeInTheDocument(); + + await openForm(); + + expect(screen.getByRole('tab', { name: 'Form' })).toHaveAttribute( + 'aria-selected', + 'true', + ); +}); + +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(screen.getByTestId('inspector-props-json')).toHaveStyle( + 'padding-top: 12px', + ); + expect((await openForm()).parentElement).toHaveStyle('padding-top: 12px'); +}); + +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 }); @@ -154,3 +236,174 @@ test('the empty state is set down too', () => { 'padding-top: 12px', ); }); + +/** + * Places two blocks in a free canvas and selects the one drawn underneath. + * + * A free canvas paints its children in the order it holds them, so the first + * child is the one nothing can be put in front of by any other means. + */ +const selectInFreeCanvas = (which: 'first' | 'second') => { + const rootId = provider.getRoot().id; + provider.updateLayout(rootId, { mode: 'free' }); + const first = provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' }); + provider.setSelection(which === 'first' ? first : second); + render(<Inspector />); + return { rootId, first, second }; +}; + +test('a block under another in a free canvas can be brought to the front', async () => { + const { first, second } = selectInFreeCanvas('first'); + + await userEvent.click(screen.getByTestId('inspector-bring-to-front')); + + expect(provider.getRoot().children).toEqual([second, first]); +}); + +test('bringing a block to the front leaves it where the author put it', async () => { + const { first } = selectInFreeCanvas('first'); + provider.updateLayout(first, { col: 5, row: 4 }); + + await userEvent.click(screen.getByTestId('inspector-bring-to-front')); + + expect(provider.getNode(first)?.layout).toMatchObject({ col: 5, row: 4 }); +}); + +test('a block over another in a free canvas can be sent to the back', async () => { + const { first, second } = selectInFreeCanvas('second'); + + await userEvent.click(screen.getByTestId('inspector-send-to-back')); + + expect(provider.getRoot().children).toEqual([second, first]); +}); + +const selectRoot = () => { + const rootId = provider.getRoot().id; + provider.setSelection(rootId); + render(<Inspector />); + return rootId; +}; + +test('the root is where the dashboard is arranged, now that the header does not ask', () => { + selectRoot(); + + expect(screen.getByTestId('layout-mode-switcher')).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: 'canvas' }); + 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('stacking is not offered in a grid canvas, where nothing overlaps', () => { + const rootId = provider.getRoot().id; + provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + const second = provider.addBuildingBlock(rootId, 1, { type: 'markdown' }); + provider.setSelection(second); + render(<Inspector />); + + expect( + screen.queryByTestId('inspector-bring-to-front'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('inspector-send-to-back'), + ).not.toBeInTheDocument(); +}); + +/** Places a child inside a canvas laid out in `mode`, and selects one of them. */ +const selectChildOfCanvasIn = (mode: 'grid' | 'flex') => { + const rootId = provider.getRoot().id; + provider.updateLayout(rootId, { mode }); + const childId = provider.addBuildingBlock(rootId, 0, { type: 'markdown' }); + provider.setSelection(childId); + render(<Inspector />); + return childId; +}; + +test('a flex child is not asked where it starts, because a flex line has no cells', () => { + selectChildOfCanvasIn('flex'); + + // `col`/`row` are grid coordinates. A flex container lays its children out + // in `children` order and reads neither, so offering them is offering a + // field that silently does nothing. + expect(screen.queryByTestId('inspector-col')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inspector-row')).not.toBeInTheDocument(); + + // Its share of the line and its height are read in every mode. + expect(screen.getByTestId('inspector-colSpan')).toBeInTheDocument(); + expect(screen.getByTestId('inspector-rowSpan')).toBeInTheDocument(); +}); + +test('a grid child is still asked where it starts', () => { + selectChildOfCanvasIn('grid'); + + expect(screen.getByTestId('inspector-col')).toBeInTheDocument(); + expect(screen.getByTestId('inspector-row')).toBeInTheDocument(); +}); + +test('a flex container is asked the things only a flex line has', () => { + const rootId = provider.getRoot().id; + provider.updateLayout(rootId, { mode: 'flex' }); + provider.setSelection(rootId); + render(<Inspector />); + + // Documented on LayoutProps as "flex only. Ignored in every other mode" — + // and until now unreachable from the panel at all, so a flex canvas could + // be chosen and then not actually arranged. + ['direction', 'wrap', 'justify', 'align'].forEach(key => + expect(screen.getByTestId(`inspector-${key}`)).toBeInTheDocument(), + ); +}); + +test('a grid container is not asked about flow, which it does not read', () => { + const rootId = provider.getRoot().id; + provider.setSelection(rootId); + render(<Inspector />); + + ['direction', 'wrap', 'justify', 'align'].forEach(key => + expect(screen.queryByTestId(`inspector-${key}`)).not.toBeInTheDocument(), + ); + // What every mode does read stays put. + ['columns', 'gap', 'rowUnit'].forEach(key => + expect(screen.getByTestId(`inspector-${key}`)).toBeInTheDocument(), + ); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx index 78b5e39430da..5e0670755131 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx @@ -21,9 +21,20 @@ import type { ReactElement, ReactNode } from 'react'; import type { dashboard as dashboardApi } from '@apache-superset/core'; import { t } from '@apache-superset/core/translation'; import { useTheme } from '@apache-superset/core/theme'; -import { Button, Form, Input, InputNumber } from '@superset-ui/core/components'; +import { + Button, + Form, + Input, + InputNumber, + Radio, + Switch, + Tabs, +} from '@superset-ui/core/components'; import { provider, useDashboardRevision } from 'src/core/dashboard/store'; +import { resolveLayoutMode } from 'src/core/dashboard/layoutStyle'; +import DashboardProperties from './DashboardProperties'; import LayoutModeSwitcher from './LayoutModeSwitcher'; +import PropsForm from './PropsForm'; type LayoutProps = dashboardApi.LayoutProps; @@ -47,6 +58,155 @@ const CHILD_FIELDS: readonly { { key: 'row', label: t('Start row') }, ]; +/** + * Which fields a mode actually reads. + * + * A field the renderer ignores is worse than a missing one: it accepts a + * value, writes it to the node, and changes nothing on screen — so an author + * concludes the layout is broken rather than that the question did not apply. + * + * `columns`, `gap` and `rowUnit` are read by every mode, flex included, and + * stay put: a flex line divides into `columns` parts and gives each child the + * share its `colSpan` names (see `resolveFlexBasis`), spaces them by `gap`, + * and sizes them off `rowUnit` (see `resolveFlexMetrics`). + * + * What differs is at the two ends. `col`/`row` are coordinates in a grid, and + * a flex line has no cells to hold them — position there is `children` order, + * which is why dragging in a flex canvas reorders rather than repositions. + * And `direction`/`wrap`/`justify`/`align` are what a flex line has instead, + * documented on `LayoutProps` as "flex only, ignored in every other mode" — + * and until now not offered anywhere, so a flex canvas could be chosen and + * then not actually arranged. + */ +const GRID_ONLY_PLACEMENT: ReadonlySet<keyof LayoutProps> = new Set([ + 'col', + 'row', +]); + +const FLEX_DIRECTIONS = [ + { value: 'row', label: t('Row') }, + { value: 'column', label: t('Column') }, +]; + +const FLEX_JUSTIFY = [ + { value: 'start', label: t('Start') }, + { value: 'center', label: t('Center') }, + { value: 'end', label: t('End') }, + { value: 'space-between', label: t('Space between') }, + { value: 'space-around', label: t('Space around') }, +]; + +const FLEX_ALIGN = [ + { value: 'stretch', label: t('Stretch') }, + { value: 'start', label: t('Start') }, + { value: 'center', label: t('Center') }, + { value: 'end', label: t('End') }, +]; + +/** + * One of a small fixed set, chosen where the choices can all be seen. + * + * `Radio.Group` rather than a dropdown, which is what `LayoutModeSwitcher` + * already uses for the layout mode a few lines above these — and the shared + * `Select` does not declare `value` or `size` among the antd props it + * exposes, so driving one from the store would mean widening a type in a + * package the rest of the app depends on. + */ +const ChoiceField = ({ + label, + test, + value, + options, + onChange, +}: { + label: string; + test: string; + value: string; + options: readonly { readonly value: string; readonly label: string }[]; + onChange: (next: string) => void; +}): ReactElement => { + const theme = useTheme(); + return ( + <Form.Item label={label} style={{ marginBottom: theme.sizeUnit * 2 }}> + <Radio.Group + size="small" + value={value} + data-test={test} + onChange={event => onChange(event.target.value as string)} + > + {options.map(option => ( + <Radio.Button key={option.value} value={option.value}> + {option.label} + </Radio.Button> + ))} + </Radio.Group> + </Form.Item> + ); +}; + +/** The four a flex line is arranged by, and no other mode reads. */ +const FlexFields = ({ + nodeId, + layout, +}: { + nodeId: string; + layout: LayoutProps | undefined; +}): ReactElement => { + const theme = useTheme(); + const set = (next: Partial<LayoutProps>) => + provider.updateLayout(nodeId, next); + + return ( + <> + <ChoiceField + label={t('Direction')} + test="inspector-direction" + value={layout?.direction ?? 'row'} + options={FLEX_DIRECTIONS} + onChange={next => set({ direction: next as LayoutProps['direction'] })} + /> + <ChoiceField + label={t('Justify')} + test="inspector-justify" + value={layout?.justify ?? 'start'} + options={FLEX_JUSTIFY} + onChange={next => set({ justify: next as LayoutProps['justify'] })} + /> + <ChoiceField + label={t('Align')} + test="inspector-align" + value={layout?.align ?? 'stretch'} + options={FLEX_ALIGN} + onChange={next => set({ align: next as LayoutProps['align'] })} + /> + <Form.Item label={t('Wrap')} style={{ marginBottom: theme.sizeUnit * 2 }}> + <Switch + size="small" + data-test="inspector-wrap" + checked={layout?.wrap !== false} + onChange={wrap => set({ wrap })} + /> + </Form.Item> + </> + ); +}; + +/** + * The panel's own buttons, sized down. + * + * Everything here acts on one block, in a rail whose width is spent on the + * fields rather than on the controls that commit them — and a panel of + * full-height buttons reads as a row of decisions before you have read what + * any of them apply to. Driven off the theme's smallest control step, the + * same one the header's icon controls sit at, so the two rails agree. + */ +const minor = (theme: ReturnType<typeof useTheme>) => ({ + height: theme.controlHeightXS, + paddingInline: theme.sizeUnit * 2, + fontSize: theme.fontSizeSM, + lineHeight: 1, +}); + const Section = ({ title, test, @@ -91,18 +251,21 @@ const NumberField = ({ value: number | undefined; test: string; onChange: (next: number | undefined) => void; -}): ReactElement => ( - <Form.Item label={label} style={{ marginBottom: 8 }}> - <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> -); +}): 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. @@ -122,13 +285,17 @@ const ContentField = ({ 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 ( - <Form.Item label={t('Content')} style={{ marginBottom: 8 }}> + <Form.Item + label={t('Content')} + style={{ marginBottom: theme.sizeUnit * 2 }} + > <Input.TextArea size="small" rows={4} @@ -145,18 +312,65 @@ const ContentField = ({ ); }; +/** + * Which of its siblings a block is drawn over. + * + * A free canvas is the only place this can be asked. `react-grid-layout` + * gives an overlapping child no `z-index` of its own, so the browser falls + * back to tree order and the container's `children` order becomes the paint + * order — the last child wins, and a block earlier in the array cannot be + * put in front of a later one by moving it, resizing it, or selecting it. + * That order was never something an author could see, let alone choose; this + * is what turns it into something they can say. + * + * The two ends are the whole control on purpose. "Forward one" and "back + * one" are the same call with an index arithmetic that only means anything + * to someone already picturing the array, and the Outline is where a longer + * stack is read and reordered. + * + * Every other mode arranges its children so they do not overlap, so there is + * nothing to be in front of and the question does not arise. + */ +const StackingControls = ({ nodeId }: { nodeId: string }): ReactElement => { + const theme = useTheme(); + return ( + <div style={{ display: 'flex', gap: theme.sizeUnit }}> + <Button + size="small" + data-test="inspector-bring-to-front" + style={minor(theme)} + onClick={() => provider.bringToFront(nodeId)} + > + {t('Bring to front')} + </Button> + <Button + size="small" + data-test="inspector-send-to-back" + style={minor(theme)} + onClick={() => provider.sendToBack(nodeId)} + > + {t('Send to back')} + </Button> + </div> + ); +}; + const format = (props: Record<string, unknown> | undefined): string => JSON.stringify(props ?? {}, null, 2); /** - * Everything a block renders from, offered whole. + * 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. + * 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 @@ -166,7 +380,7 @@ const format = (props: Record<string, unknown> | undefined): string => * that, deleting a line here would silently do nothing and the block would * go on rendering from the value it appeared to lose. */ -const PropsEditor = ({ +const PropsJsonEditor = ({ nodeId, props, }: { @@ -195,7 +409,12 @@ const PropsEditor = ({ return ( <> - <Form.Item label={t('Properties (JSON)')} style={{ marginBottom: 8 }}> + {/* 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} @@ -221,6 +440,7 @@ const PropsEditor = ({ size="small" buttonStyle="primary" data-test="inspector-props-apply" + style={minor(theme)} disabled={parsed === undefined || !dirty} onClick={() => { if (parsed === undefined) { @@ -240,6 +460,7 @@ const PropsEditor = ({ <Button size="small" data-test="inspector-props-revert" + style={minor(theme)} disabled={!dirty} onClick={() => setDraft(accepted)} > @@ -250,6 +471,79 @@ const PropsEditor = ({ ); }; +/** + * 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. + * + * JSON is what the panel opens on. It is the half that works on a block + * placed a moment ago, which has no properties yet and so no fields; landing + * on a form with nothing in it would read as a tab that does not work. + * + * 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="json" + data-test="inspector-props-tabs" + items={[ + { + key: 'json', + label: t('JSON'), + children: ( + <Form + layout="vertical" + component="div" + style={inset} + data-test="inspector-props-json" + > + <PropsJsonEditor nodeId={nodeId} props={props} /> + </Form> + ), + }, + { + key: 'form', + label: t('Form'), + children: ( + <div style={inset}> + <PropsForm nodeId={nodeId} props={props} /> + </div> + ), + }, + ]} + /> + ); +}; + /** * Property editing over the selected node. * @@ -290,6 +584,17 @@ export default function Inspector(): ReactElement { } const isContainer = node.children !== undefined; + // 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. Arranging is the one thing + // the two have in common, and it comes below as it does for any container. + const isRoot = node.id === provider.getRoot().id; + const parentId = provider.getParentId(node.id); + const parent = + parentId === undefined ? undefined : provider.getNode(parentId); + // Only where children can overlap is there anything to be in front of. + const stacks = + parent !== undefined && resolveLayoutMode(parent.layout) === 'free'; 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 @@ -300,30 +605,41 @@ export default function Inspector(): ReactElement { return ( <div data-test="inspector" style={{ ...inset, fontSize: theme.fontSizeSM }}> - <p - data-test="inspector-identity" - style={{ - margin: 0, - color: theme.colorTextSecondary, - wordBreak: 'break-all', - }} - > - {node.type} · {node.id} - </p> + {isRoot ? ( + <DashboardProperties /> + ) : ( + <p + data-test="inspector-identity" + style={{ + margin: 0, + color: theme.colorTextSecondary, + wordBreak: 'break-all', + }} + > + {node.type} · {node.id} + </p> + )} - {/* Labels above their fields: beside them halves the width left for the - control, in the panel that most needs the room. */} - <Form layout="vertical" component="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 && ( - <ContentField - nodeId={node.id} - content={typeof content === 'string' ? content : ''} - /> + <Form layout="vertical" component="div"> + <ContentField + nodeId={node.id} + content={typeof content === 'string' ? content : ''} + /> + </Form> )} <PropsEditor nodeId={node.id} props={node.props} /> </Section> + )} + {/* Labels above their fields: beside them halves the width left for the + control, in the panel that most needs the room. */} + <Form layout="vertical" component="div"> {isContainer && ( <Section title={t('Arrangement')} @@ -342,34 +658,53 @@ export default function Inspector(): ReactElement { } /> ))} + {resolveLayoutMode(node.layout) === 'flex' && ( + <FlexFields nodeId={node.id} layout={node.layout} /> + )} </div> </Section> )} - <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> + {/* 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 && ( + <Section title={t('Placement')} test="inspector-section-placement"> + {CHILD_FIELDS.filter( + field => + !( + parent !== undefined && + resolveLayoutMode(parent.layout) === 'flex' && + GRID_ONLY_PLACEMENT.has(field.key) + ), + ).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 }) + } + /> + ))} + {stacks && <StackingControls nodeId={node.id} />} + </Section> + )} </Form> - <Button - size="small" - danger - data-test="inspector-delete" - style={{ marginTop: theme.sizeUnit * 3 }} - onClick={() => provider.removeBuildingBlock(node.id)} - > - {t('Delete')} - </Button> + {/* `removeBuildingBlock` refuses the root, so offering it here would be + a button that only ever raises. */} + {!isRoot && ( + <Button + size="small" + danger + data-test="inspector-delete" + style={{ ...minor(theme), marginTop: theme.sizeUnit * 3 }} + onClick={() => provider.removeBuildingBlock(node.id)} + > + {t('Delete')} + </Button> + )} </div> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx new file mode 100644 index 000000000000..5cc7d7e87f39 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx @@ -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. + */ +import { useMemo } from 'react'; +import type { ReactElement } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { 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'; + +/** + * 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 ( + <div + 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(', '), + ), + )} + </div> + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts new file mode 100644 index 000000000000..16c57db39ea4 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.test.ts @@ -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: {}, + }); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts new file mode 100644 index 000000000000..6efe1fc07ffd --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/inferPropsSchema.ts @@ -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); +} From 6cb15c5d9ea82583d6988f0b286527896f0037c6 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:07:16 +0200 Subject: [PATCH 15/19] feat(dashboard-v2): let the editor panel be got out of the way The canvas is the work; this rail is how you act on it. An author reading a dashboard at full width had no way to reclaim the 500px it holds, short of dragging its edge all the way in and back out again. The fold control rides the tab bar rather than sitting above it, because closing the panel is done to the panel, and a row of its own for one icon would cost that row's height on every screen that never uses it. Closing is not resizing, so a closed panel keeps the width it was opened at: a panel that reopened at the default would silently discard a width the author had already chosen. The strip left behind is exactly as wide as the one control on it and offers no edge to drag, since there is nothing there to size and a draggable strip would be a third state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../DashboardBuilderV2/EditorPanel.test.tsx | 42 +++++++++++ .../pages/DashboardBuilderV2/EditorPanel.tsx | 74 ++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx index b5fdce76afb6..9f1a471d440e 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.test.tsx @@ -218,3 +218,45 @@ test('a palette row can actually be dragged, as its grip promises', () => { '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(); +}); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx index 0007fc403c9e..523bd2152cfa 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx @@ -20,7 +20,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { KeyboardEvent, PointerEvent, ReactElement } from 'react'; import { t } from '@apache-superset/core/translation'; import { useTheme } from '@apache-superset/core/theme'; -import { Tabs } from '@superset-ui/core/components'; +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'; @@ -62,6 +63,8 @@ export default function EditorPanel({ const theme = useTheme(); 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); @@ -147,6 +150,52 @@ export default function EditorPanel({ } }; + /** + * 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 ( + <aside + data-test="editor-panel" + aria-label={t('Editor panel')} + style={{ + flexShrink: 0, + display: 'flex', + justifyContent: 'center', + padding: theme.sizeUnit, + borderRight: `1px solid ${theme.colorBorder}`, + background: theme.colorBgContainer, + }} + > + <Button + size="small" + 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)} + style={{ + height: theme.controlHeightSM, + paddingInline: theme.sizeUnit, + }} + > + <Icons.MenuUnfoldOutlined iconSize="m" /> + </Button> + </aside> + ); + } + return ( <aside ref={panel} @@ -171,6 +220,29 @@ export default function EditorPanel({ onChange={key => setTab(key as PanelTab)} size="small" style={{ flex: 1, minHeight: 0 }} + // 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 + size="small" + 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)} + style={{ + height: theme.controlHeightSM, + paddingInline: theme.sizeUnit, + }} + > + <Icons.MenuFoldOutlined iconSize="m" /> + </Button> + ), + }} items={[ { key: 'blocks', From c3c1dee74e2c84e1bfd24a0a6e078dd555daf1fc Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:07:45 +0200 Subject: [PATCH 16/19] feat(dashboard-v2): sort the header by what each control acts on The bar above the canvas had collected everything: what the dashboard is called, whether it is saved, and also how the canvas lays blocks out and when it reloads. The last two are not chrome about the dashboard -- they act on the blocks in front of you and are reached for while looking at them -- so they move into the canvas's own top-left corner. Arrange is a route rather than a second control. How a container lays out its children is a property of that container, asked with the columns and the gap it works alongside, and a copy of the switcher here would be a second thing to keep agreeing with the first: it selects the root, and the panel brings Properties forward on a selection it did not make. That would have made the mode unreachable on a blank dashboard, where the placeholder renders instead of the root and there is nothing to select -- which is exactly when the mode is worth asking, since whatever is placed next lands in the one already chosen. The placeholder is now the dashboard, and selects it. Refresh is the opposite: named, and honest that it cannot work. There is no row behind this page and no query to re-run, so it says so rather than doing nothing quietly. `Inert` is what says that in one place -- disabled, wrapped in a span so the tooltip survives the disabled button. What is left on the bar is what the dashboard is, in the order it is read: the name, then who owns it and when it last changed, then the saving. History moves beside Save because that is what it is a history of. Templates, History and Save are what an author leaves with and are sized accordingly. The canvas padding is two past the token so the corner controls clear the frame the root draws inside it, written as an offset rather than a literal so it still moves with the scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../DashboardBuilderV2/CanvasControls.tsx | 103 +++++++++++ .../DashboardHeader.test.tsx | 79 +++++++-- .../DashboardBuilderV2/DashboardHeader.tsx | 162 +++++++++--------- .../pages/DashboardBuilderV2/InertControl.tsx | 99 +++++++++++ .../pages/DashboardBuilderV2/index.test.tsx | 71 ++++++-- .../src/pages/DashboardBuilderV2/index.tsx | 34 +++- 6 files changed, 439 insertions(+), 109 deletions(-) create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx create mode 100644 superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx diff --git a/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx new file mode 100644 index 000000000000..d161a53891b7 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx @@ -0,0 +1,103 @@ +/** + * 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 { useTheme } from '@apache-superset/core/theme'; +import { Button } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { provider } from 'src/core/dashboard/store'; +import Inert, { compact } from './InertControl'; + +/** + * What acts on the canvas as a whole, in the canvas's own corner. + * + * These two are not chrome about the dashboard — not what it is called, who + * owns it, or whether it is published. They act on the blocks in front of you, + * and both are reached for while looking at them, which is why they sit here + * rather than on the bar above. + * + * **Arrange** is a route, not a control. How a container lays out its + * children is a property of that container and is asked with the rest of them + * — the columns, the gap, the row height it works alongside. That is the + * right home for it and also further from hand than something permanently on + * screen, so this is the way back to it. A second copy of the switcher would + * be a second thing to keep agreeing with the first; selecting the root is + * all this does, and the editor panel brings Properties forward on a + * selection it did not make itself. It stays live on a page where almost + * nothing is, because selecting a node this page already holds in memory + * needs no dashboard row. + * + * **Refresh** is the opposite: named, and honest that it cannot work. There + * is no row behind this page and no query to re-run, so it says so rather + * than doing nothing quietly. + */ +export default function CanvasControls(): ReactElement { + const theme = useTheme(); + + return ( + <div + data-test="canvas-controls" + // In the canvas's own padding, and sized to fit inside it: at the next + // step up these are taller than the inset and clip the frame the root + // draws, which reads as a collision rather than as a corner. + // + // Raised because the root is positioned too, and a positioned sibling + // earlier in the tree is painted under it — the same tree-order rule + // that decides which of two overlapping blocks wins. + style={{ + position: 'absolute', + top: theme.sizeUnit, + left: theme.sizeUnit, + zIndex: 1, + display: 'flex', + alignItems: 'center', + // Two icons of the same size and colour sitting a hair apart read as + // one control with two halves. The space is what separates arranging + // the canvas from reloading it. + gap: theme.sizeUnit * 3, + }} + > + <Button + size="small" + buttonStyle="link" + aria-label={t('Arrange dashboard')} + data-test="canvas-arrange" + tooltip={t('Arrange dashboard — choose how the canvas lays blocks out')} + placement="bottom" + style={{ ...compact(theme), paddingInline: theme.sizeUnit }} + onClick={() => provider.setSelection(provider.getRoot().id)} + > + <Icons.LayoutOutlined iconSize="s" /> + </Button> + <Inert + label={t('Refresh dashboard')} + test="canvas-refresh" + buttonStyle="link" + // Zeroed because it is not this row's to set. A disabled control is + // wrapped in a span so its tooltip survives, and Superset's button + // styles give a wrapped button a left margin meant for a row of them + // — so this one sat further from its neighbour than the neighbour sat + // from anything, and the pair's spacing stopped being the `gap` above. + style={{ paddingInline: theme.sizeUnit, marginLeft: 0 }} + > + <Icons.ReloadOutlined iconSize="s" /> + </Inert> + </div> + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx index 4642cf629556..2374cad31d86 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.test.tsx @@ -23,25 +23,33 @@ 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', () => { - render(<DashboardHeader />); + 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-refresh')).toBeInTheDocument(); 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', () => { - render(<DashboardHeader />); + 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 @@ -51,26 +59,69 @@ test('everything the builder cannot actually do is disabled, not silently dead', 'header-templates', 'header-history', 'header-favorite', - 'header-refresh', 'header-undo', 'header-redo', 'header-save', ].forEach(test => expect(screen.getByTestId(test)).toBeDisabled()); }); -test('the layout switcher is the one live control, and it edits the tree', async () => { - render(<DashboardHeader />); - const rootId = provider.getRoot().id; +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, and are both + // reached for while looking at it. They sit in its corner — see + // CanvasControls. What stays here is what the dashboard is, not what is + // being done to the blocks on it. + 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'); +}); - // Live precisely because its state is in the tree rather than in a row - // this page does not have. - await userEvent.click(screen.getByTestId('layout-mode-flex')); +test('the header does not claim a dashboard with no row behind it was saved', () => { + renderHeader(); - expect(provider.getNode(rootId)?.layout?.mode).toBe('flex'); + // 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 () => { - render(<DashboardHeader />); + renderHeader(); await userEvent.type(screen.getByTestId('header-title'), 'Vaccine rollout'); await userEvent.tab(); @@ -83,14 +134,14 @@ test('the dashboard is nameable, and the name is stored on the dashboard', async test('the title shows a rename made anywhere else', () => { provider.updateProps(provider.getRoot().id, { title: 'From the assistant' }); - render(<DashboardHeader />); + 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' }); - render(<DashboardHeader />); + renderHeader(); await userEvent.clear(screen.getByTestId('header-title')); await userEvent.tab(); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx index 00d04c102a56..27d6464de14a 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx @@ -17,74 +17,75 @@ * under the License. */ import { useEffect, useState } from 'react'; -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; +import { useSelector } from 'react-redux'; import { t } from '@apache-superset/core/translation'; import { useTheme } from '@apache-superset/core/theme'; -import { - Button, - type ButtonProps, - Input, - PublishedLabel, -} from '@superset-ui/core/components'; +import { 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 LayoutModeSwitcher from './LayoutModeSwitcher'; - -const NOT_AVAILABLE = t('Not available yet'); +import Inert from './InertControl'; /** - * Header controls, sized down. + * 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. * - * The bar is chrome around the work rather than the work itself, and every - * pixel it takes is one the canvas does not get. Driven from the theme's own - * smallest control step rather than a literal, so it tracks the scale the - * rest of the app is built on instead of drifting from it. + * 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. */ -const compact = (theme: ReturnType<typeof useTheme>) => ({ - height: theme.controlHeightXS, - paddingInline: theme.sizeUnit * 1.5, - fontSize: theme.fontSizeSM, - lineHeight: 1, -}); - /** - * An affordance that is present, named and honest about not working. - * - * Most of this header is one. The builder keeps its tree in memory and has - * no dashboard row behind it: nothing here 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. + * The signed-in person's name. * - * `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. + * 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 Inert = ({ - label, - test, - buttonStyle, - children, -}: { - label: string; - test: string; - buttonStyle?: ButtonProps['buttonStyle']; - children: ReactNode; -}): ReactElement => { - const theme = useTheme(); +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 ( - <Button - size="small" - buttonStyle={buttonStyle} - disabled - aria-label={label} - data-test={test} - tooltip={`${label} — ${NOT_AVAILABLE}`} - placement="bottom" - style={compact(theme)} - > - {children} - </Button> + <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> ); }; @@ -141,10 +142,17 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { * 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, whether it - * is published. On the right is what an author does to the tree in front of - * them, and the one live control among them is the layout: it is the only one - * whose state is in the tree rather than in a row this page does not have. + * 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(); @@ -164,16 +172,13 @@ export default function DashboardHeader(): ReactElement { background: theme.colorBgContainer, }} > - {/* Where this dashboard came from and where it has been: one offers a - starting point to build from, the other the record of what has - already happened to it. Both are asked before the work rather than - during it, which is why they lead the bar. */} - <Inert label={t('Templates')} test="header-templates"> + {/* 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> - <Inert label={t('History')} test="header-history"> - {t('History')} - </Inert> <Title nodeId={root.id} title={typeof root.props?.title === 'string' ? root.props.title : ''} @@ -186,6 +191,10 @@ export default function DashboardHeader(): ReactElement { <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 /> <span style={{ @@ -195,14 +204,6 @@ export default function DashboardHeader(): ReactElement { gap: theme.sizeUnit * 2, }} > - <LayoutModeSwitcher nodeId={root.id} /> - <Inert - label={t('Refresh dashboard')} - test="header-refresh" - buttonStyle="link" - > - <Icons.ReloadOutlined iconSize="m" /> - </Inert> {/* 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. */} @@ -212,7 +213,14 @@ export default function DashboardHeader(): ReactElement { <Inert label={t('Redo')} test="header-redo"> <Icons.RedoOutlined iconSize="s" /> </Inert> - <Inert label={t('Save')} test="header-save"> + {/* 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> </span> diff --git a/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx new file mode 100644 index 000000000000..072372dcfa19 --- /dev/null +++ b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx @@ -0,0 +1,99 @@ +/** + * 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 { useTheme } from '@apache-superset/core/theme'; +import { Button, type ButtonProps } from '@superset-ui/core/components'; + +const NOT_AVAILABLE = t('Not available yet'); + +/** + * This prototype's controls, at two sizes. + * + * Nothing here is drawn at full size: these bars 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 the theme has is + * read slowly; an icon is recognised by its shape, and loses nothing there. + * + * Both are driven off the theme's own control scale rather than literals, so + * they track the size the rest of the app is built on instead of drifting. + */ +export const named = (theme: ReturnType<typeof useTheme>) => ({ + height: theme.controlHeightSM, + paddingInline: theme.sizeUnit * 2.5, + fontSize: theme.fontSize, + lineHeight: 1, +}); + +export const compact = (theme: ReturnType<typeof useTheme>) => ({ + height: theme.controlHeightXS, + paddingInline: theme.sizeUnit * 1.5, + fontSize: theme.fontSizeSM, + lineHeight: 1, +}); + +/** + * 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 { + const theme = useTheme(); + return ( + <Button + size="small" + buttonStyle={buttonStyle} + disabled + aria-label={label} + data-test={test} + tooltip={`${label} — ${NOT_AVAILABLE}`} + placement="bottom" + style={{ ...(reads ? named(theme) : compact(theme)), ...style }} + > + {children} + </Button> + ); +} diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx index a08195d16b2b..25d03df42005 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx @@ -31,33 +31,70 @@ beforeEach(() => { provider.reset(); }); -test('a blank dashboard still offers a layout to arrange it in', () => { - render(<DashboardBuilderV2 />); +const renderPage = () => render(<DashboardBuilderV2 />, { useRedux: true }); + +test('a blank dashboard can still be reached, and so can the layout it arranges in', async () => { + renderPage(); - // The page a `/dashboard/v2/new/` load lands on has nothing on it yet, and - // that is exactly when someone reaches for the layout control: whatever is - // placed next lands in the mode already chosen, rather than being placed - // and then rearranged. - expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); // The canvas is no longer chat-only: a palette sits beside it, so the // empty state names both ways in. expect( screen.getByText('Add a building block, or ask the assistant to start'), ).toBeInTheDocument(); + + // A `/dashboard/v2/new/` load lands on nothing, and that is exactly when + // someone reaches for the layout control: whatever is placed next lands in + // the mode already chosen, rather than being placed and then rearranged. + // Arranging is asked in the root's own properties, so the blank canvas has + // to be selectable or the mode is unreachable until something is placed. + await userEvent.click(screen.getByTestId('empty-canvas')); + + expect(provider.getSelection()).toBe(provider.getRoot().id); + expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); }); -test('the layout control survives the first block being added', () => { +test('selecting the root offers the layout once blocks have been placed too', async () => { provider.addBuildingBlock(provider.getRoot().id, 0, { type: 'markdown' }); + renderPage(); + + provider.setSelection(provider.getRoot().id); + + expect(await screen.findByTestId('layout-mode-switcher')).toBeInTheDocument(); +}); + +test('the canvas carries the route to how it is arranged', async () => { + provider.addBuildingBlock(provider.getRoot().id, 0, { type: 'markdown' }); + renderPage(); + + // On the thing it arranges rather than on the bar above it: choosing how + // blocks lay out is done while looking at the blocks, and the control it + // leads to is one selection away in the root's own properties. + await userEvent.click(screen.getByTestId('canvas-arrange')); + + expect(provider.getSelection()).toBe(provider.getRoot().id); + expect(screen.getByTestId('layout-mode-switcher')).toBeInTheDocument(); +}); + +test('the arrange shortcut is offered on a blank canvas too', () => { + renderPage(); + + // A blank dashboard is exactly when the mode is chosen — whatever is placed + // next lands in it. + expect(screen.getByTestId('canvas-arrange')).toBeInTheDocument(); +}); - render(<DashboardBuilderV2 />); +test('refreshing sits with arranging, and is honest about not working', () => { + renderPage(); - expect(screen.getAllByTestId('layout-mode-switcher').length).toBeGreaterThan( - 0, - ); + // Both act on the canvas as a whole rather than on anything placed in it, + // so they are reached for from the same corner. Refreshing is still the + // affordance this builder cannot honour: there is no dashboard row behind + // the page and nothing to re-read. + expect(screen.getByTestId('canvas-refresh')).toBeDisabled(); }); test('the page is a header, an editor panel and a canvas', () => { - render(<DashboardBuilderV2 />); + renderPage(); expect(screen.getByTestId('dashboard-header')).toBeInTheDocument(); expect(screen.getByTestId('editor-panel')).toBeInTheDocument(); @@ -65,7 +102,7 @@ test('the page is a header, an editor panel and a canvas', () => { }); test('placing a block from the palette puts it on the dashboard and selects it', async () => { - render(<DashboardBuilderV2 />); + renderPage(); await userEvent.click(screen.getByTestId('palette-markdown')); @@ -77,7 +114,7 @@ test('placing a block from the palette puts it on the dashboard and selects it', }); test('a block placed while a container is selected goes inside it', async () => { - render(<DashboardBuilderV2 />); + renderPage(); await userEvent.click(screen.getByTestId('palette-canvas')); const sectionId = provider.getSelection()!; @@ -92,7 +129,7 @@ test('a block placed while a container is selected goes inside it', async () => }); test('a block placed while a leaf is selected goes beside it, not inside it', async () => { - render(<DashboardBuilderV2 />); + renderPage(); await userEvent.click(screen.getByTestId('palette-markdown')); const firstId = provider.getSelection()!; @@ -105,7 +142,7 @@ test('a block placed while a leaf is selected goes beside it, not inside it', as }); test('clicking the canvas itself clears the selection', async () => { - render(<DashboardBuilderV2 />); + renderPage(); await userEvent.click(screen.getByTestId('palette-markdown')); expect(provider.getSelection()).toBeDefined(); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 90eaa94e38cf..6f794484e49f 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -27,6 +27,7 @@ import { placeBlock } from 'src/core/dashboard/placement'; import { chat } from 'src/core/chat'; import BuildingBlockView from 'src/core/dashboard/BuildingBlockView'; import { dashboardClientTools } from './clientTools'; +import CanvasControls from './CanvasControls'; import DashboardHeader from './DashboardHeader'; import EditorPanel from './EditorPanel'; @@ -44,7 +45,13 @@ const Canvas = styled.div` flex: 1; min-height: 0; overflow: auto; - padding: ${theme.paddingLG}px; + /* What the canvas's own corner controls are positioned against. */ + position: relative; + /* Two past the token, so the corner controls clear the frame the root + draws inside this padding rather than sitting hard against it. Written + as an offset from the token rather than as a literal, so it still moves + with the scale the rest of the app is built on. */ + padding: ${theme.paddingLG + 2}px; `} `; @@ -143,13 +150,38 @@ export default function DashboardBuilderV2() { } }} > + {/* The canvas's own corner: what acts on the whole of it rather + than on anything placed in it, and so belongs to it rather than + to the bar above. First in the tree, raised over the root by its + own z-index — see CanvasControls. */} + <CanvasControls /> {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 vertical align="center" justify="center" gap="small" + // 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); + } + }} > <Icons.AppstoreOutlined iconSize="xl" /> <Typography.Text type="secondary"> From e27290bfd8ba4fc89a4e37f9339d057fa8a9a5bd Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:21:03 +0200 Subject: [PATCH 17/19] feat: adds the copy button for the json --- .../DashboardBuilderV2/Inspector.test.tsx | 91 ++++++++++++++----- .../pages/DashboardBuilderV2/Inspector.tsx | 71 ++++++++++++--- 2 files changed, 128 insertions(+), 34 deletions(-) diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx index 060faf87d252..574c9d8e86b0 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.test.tsx @@ -33,6 +33,16 @@ 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, @@ -64,17 +74,18 @@ test('content a block already has is what the field shows', () => { expect(screen.getByTestId('inspector-content')).toHaveValue('Welcome'); }); -test('a block with no prose field is still authorable through its properties', () => { +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(screen.getByTestId('inspector-props')).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"]}}' }, @@ -89,6 +100,7 @@ test('applying properties writes them to the block', async () => { 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}' }, @@ -107,8 +119,9 @@ test('a key deleted from the properties stops reaching the block', async () => { ); }); -test('malformed properties cannot be applied, and stay on screen to be fixed', () => { +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": ' }, @@ -121,8 +134,9 @@ test('malformed properties cannot be applied, and stay on screen to be fixed', ( expect(provider.getNode(id)?.props?.kept).toBe(true); }); -test('properties that are not an object are refused', () => { +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]' }, @@ -131,31 +145,22 @@ test('properties that are not an object are refused', () => { expect(screen.getByTestId('inspector-props-apply')).toBeDisabled(); }); -/** Brings the generated form forward; the panel opens on the JSON half. */ -const openForm = async () => { - await userEvent.click(screen.getByRole('tab', { name: 'Form' })); - return screen.findByTestId('inspector-props-form'); -}; +/** 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. JSON is - // where the shape is changed — a key added or dropped — and the form is - // where the values in that shape are filled in. JSON is what it opens on: - // a block placed a moment ago has no properties, and so no fields. - expect(screen.getByRole('tab', { name: 'JSON' })).toHaveAttribute( + // 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', ); - expect(screen.getByTestId('inspector-props')).toBeInTheDocument(); - await openForm(); - expect(screen.getByRole('tab', { name: 'Form' })).toHaveAttribute( - 'aria-selected', - 'true', - ); + expect(await openJson()).toBeInTheDocument(); }); test('the form is built from the properties the block is actually holding', async () => { @@ -192,10 +197,53 @@ test('each half of the properties editor is set down from the tabs above it', as // 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', ); - expect((await openForm()).parentElement).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 () => { @@ -210,6 +258,7 @@ test('a block with no properties yet says where they are added', async () => { test('reverting restores what the block still has', async () => { select('echarts', { kept: true }); + await openJson(); fireEvent.change(screen.getByTestId('inspector-props'), { target: { value: '{}' }, diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx index 5e0670755131..4d4d756d3f89 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx @@ -30,6 +30,8 @@ import { Switch, 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 { resolveLayoutMode } from 'src/core/dashboard/layoutStyle'; import DashboardProperties from './DashboardProperties'; @@ -358,6 +360,9 @@ const StackingControls = ({ nodeId }: { nodeId: string }): ReactElement => { 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. * @@ -392,6 +397,17 @@ const PropsJsonEditor = ({ 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 { @@ -466,6 +482,32 @@ const PropsJsonEditor = ({ > {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 + size="small" + 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={{ ...minor(theme), marginLeft: 'auto' }} + onClick={() => { + copyTextToClipboard(() => Promise.resolve(draft)); + setCopied(true); + }} + > + {copied ? ( + <Icons.CheckOutlined iconSize="s" /> + ) : ( + <Icons.CopyOutlined iconSize="s" /> + )} + </Button> </div> </> ); @@ -482,9 +524,12 @@ const PropsJsonEditor = ({ * actually filled in, with a control that suits its type instead of quoting * and escaping inside a string. * - * JSON is what the panel opens on. It is the half that works on a block - * placed a moment ago, which has no properties yet and so no fields; landing - * on a form with nothing in it would read as a tab that does not work. + * 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 @@ -513,9 +558,18 @@ const PropsEditor = ({ return ( <Tabs size="small" - defaultActiveKey="json" + 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'), @@ -530,15 +584,6 @@ const PropsEditor = ({ </Form> ), }, - { - key: 'form', - label: t('Form'), - children: ( - <div style={inset}> - <PropsForm nodeId={nodeId} props={props} /> - </div> - ), - }, ]} /> ); From 085fa18b06c40a951dcb4736f4be9951ba800fc9 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 17:57:04 +0200 Subject: [PATCH 18/19] style: adds color to the icon --- .../core/dashboard/BuildingBlockView.test.tsx | 11 +++ .../src/core/dashboard/BuildingBlockView.tsx | 67 ++++++++++++++----- .../dashboard/blocks/CanvasBlock.test.tsx | 17 +++++ .../src/core/dashboard/blocks/CanvasBlock.tsx | 10 ++- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx index 6f0bd565a7cb..eb01deb10bd8 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.test.tsx @@ -59,6 +59,17 @@ test('the delete control does not have to be found first', () => { 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} />); diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index 24e4bf1c3567..10078eec1bc5 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -18,7 +18,7 @@ */ import { forwardRef, type HTMLAttributes } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; +import { css, styled, useTheme } from '@apache-superset/core/theme'; import { Flex, Typography } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { ErrorBoundary } from 'src/components'; @@ -52,6 +52,47 @@ function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) { ); } +/** + * The control that takes a block off the dashboard. + * + * Written as a styled component rather than inlined with the rest of the + * header because what it needs is a hover state, and an inline style cannot + * express one. + * + * It sits quiet until pointed at: a bin on every block, all of them lit, + * would make a canvas read as a row of things about to be deleted. Under the + * pointer the icon takes the colour the app gives a destructive action + * everywhere else, so what it does is answered before the click rather than + * after it. `:focus-visible` gets the same treatment, because someone + * arriving by Tab is owed the same warning as someone arriving by mouse. + * + * The icon and nothing else. The button is a 24px square only so there is + * enough of it to hit, and filling that square would light a shape the eye + * reads as a new control appearing in the header rather than as the one + * already there answering. + */ +const RemoveButton = styled.button` + ${({ theme }) => css` + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: ${theme.controlHeightSM}px; + height: ${theme.controlHeightSM}px; + padding: 0; + border: none; + background: none; + color: ${theme.colorTextTertiary}; + cursor: pointer; + transition: color ${theme.motionDurationMid} ease-in-out; + + &:hover, + &:focus-visible { + color: ${theme.colorError}; + } + `} +`; + interface BuildingBlockViewProps extends HTMLAttributes<HTMLDivElement> { nodeId: string; } @@ -241,7 +282,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( > {blockLabel(node.type, node.props)} </Typography.Text> - <button + <RemoveButton type="button" data-block-remove data-test={`block-remove-${nodeId}`} @@ -253,22 +294,14 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( event.stopPropagation(); provider.removeBuildingBlock(nodeId); }} - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flex: '0 0 auto', - width: headerHeight, - height: headerHeight, - padding: 0, - border: 'none', - background: 'none', - color: theme.colorTextTertiary, - cursor: 'pointer', - }} > - <Icons.CloseOutlined iconSize="s" /> - </button> + {/* 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. */} + <Icons.DeleteOutlined iconSize="s" /> + </RemoveButton> </div> )} {/* The block's own box, which is the whole of this element's minus diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx index 896523bfe6a1..46ec604cb4f2 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx @@ -42,17 +42,20 @@ jest.mock('react-grid-layout/legacy', () => ({ compactType, allowOverlap, draggableCancel, + resizeHandles, }: { children: React.ReactNode; compactType: string | null; allowOverlap?: boolean; draggableCancel?: string; + resizeHandles?: string[]; }) => ( <div data-test="rgl" data-compact-type={String(compactType)} data-allow-overlap={String(!!allowOverlap)} data-draggable-cancel={draggableCancel ?? ''} + data-resize-handles={(resizeHandles ?? []).join(',')} > {children} </div> @@ -86,6 +89,20 @@ test('a grid compacts its children and does not let them overlap', () => { expect(grid).toHaveAttribute('data-allow-overlap', 'false'); }); +test('the corner a block removes itself from is not also a resize handle', () => { + withMode('grid'); + + // react-grid-layout appends its handles after a block's own content, so the + // north-east one landed on the remove control and took every click aimed at + // it -- `elementFromPoint` at that button's centre returned the handle. A + // handle nobody can grab is worse than no handle: the corner still looks + // resizable, and answers a drag that starts a pixel to either side. + expect(screen.getByTestId('rgl')).toHaveAttribute( + 'data-resize-handles', + 'se,sw,nw', + ); +}); + test('a container that named no mode is drawn as a grid', () => { withMode(); diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx index 21fbe8cfe1fd..adcc985ade7f 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.tsx @@ -304,7 +304,15 @@ export default function CanvasBlock({ nodeId }: { nodeId: string }) { compactType={free ? null : 'vertical'} allowOverlap={free} preventCollision={false} - resizeHandles={['se', 'sw', 'ne', 'nw']} + // Every corner but the top-right one, which a block spends on its + // remove control. react-grid-layout appends its handles after the + // block's own content, so a 20px handle sat over that button and took + // every click aimed at it — `elementFromPoint` at the button's centre + // returned the handle. A handle nobody can grab is worse than no + // handle: the corner looks resizable and answers a drag that starts + // one pixel away. Three corners still size a block, and the fourth + // does the one thing that corner is labelled for. + resizeHandles={['se', 'sw', 'nw']} // A nested canvas that declares its own `rowUnit` independently of // the outer `rowSpan` that placed it can end up needing more (or // less) height than that outer placement actually reserves — see From 80a58e3f50650f798dd4cbc6d19bcf9b7e84d0a0 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <enzomartellucci@gmail.com> Date: Thu, 6 Aug 2026 23:09:02 +0200 Subject: [PATCH 19/19] style(dashboard-v2): improve the style of the controls --- .../src/core/dashboard/BuildingBlockView.tsx | 130 +++--- .../dashboard/blocks/CanvasBlock.test.tsx | 13 +- .../DashboardBuilderV2/CanvasControls.tsx | 7 +- .../DashboardBuilderV2/DashboardHeader.tsx | 105 +++-- .../DashboardProperties.tsx | 16 +- .../pages/DashboardBuilderV2/EditorPanel.tsx | 148 ++++--- .../pages/DashboardBuilderV2/InertControl.tsx | 38 +- .../pages/DashboardBuilderV2/Inspector.tsx | 243 +++++++---- .../DashboardBuilderV2/LayoutModeSwitcher.tsx | 65 +-- .../src/pages/DashboardBuilderV2/Outline.tsx | 197 +++++++-- .../src/pages/DashboardBuilderV2/Palette.tsx | 406 ++++++++++++------ .../pages/DashboardBuilderV2/PropsForm.tsx | 145 ++++++- .../pages/DashboardBuilderV2/index.test.tsx | 4 +- .../src/pages/DashboardBuilderV2/index.tsx | 40 +- 14 files changed, 1070 insertions(+), 487 deletions(-) diff --git a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx index 10078eec1bc5..d0afe24fc45e 100644 --- a/superset-frontend/src/core/dashboard/BuildingBlockView.tsx +++ b/superset-frontend/src/core/dashboard/BuildingBlockView.tsx @@ -19,7 +19,7 @@ import { forwardRef, type HTMLAttributes } from 'react'; import { t } from '@apache-superset/core/translation'; import { css, styled, useTheme } from '@apache-superset/core/theme'; -import { Flex, Typography } from '@superset-ui/core/components'; +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'; @@ -53,46 +53,48 @@ function UnsupportedBlockPlaceholder({ nodeId }: { nodeId: string }) { } /** - * The control that takes a block off the dashboard. + * A block's name, and what can be done to the block. * - * Written as a styled component rather than inlined with the rest of the - * header because what it needs is a hover state, and an inline style cannot - * express one. - * - * It sits quiet until pointed at: a bin on every block, all of them lit, - * would make a canvas read as a row of things about to be deleted. Under the - * pointer the icon takes the colour the app gives a destructive action - * everywhere else, so what it does is answered before the click rather than - * after it. `:focus-visible` gets the same treatment, because someone - * arriving by Tab is owed the same warning as someone arriving by mouse. - * - * The icon and nothing else. The button is a 24px square only so there is - * enough of it to hit, and filling that square would light a shape the eye - * reads as a new control appearing in the header rather than as the one - * already there answering. + * 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 RemoveButton = styled.button` +const BlockHeader = styled.div` ${({ theme }) => css` display: flex; align-items: center; - justify-content: center; - flex: 0 0 auto; - width: ${theme.controlHeightSM}px; + gap: ${theme.sizeUnit}px; height: ${theme.controlHeightSM}px; - padding: 0; - border: none; - background: none; - color: ${theme.colorTextTertiary}; - cursor: pointer; - transition: color ${theme.motionDurationMid} ease-in-out; - - &:hover, - &:focus-visible { - color: ${theme.colorError}; - } + /* Aligned with the inset every block's own content sits at, so a block's + name reads as the head of the box beneath it rather than as something + floating loose to its left. The right side stays tight: the remove + button is a square target of its own and centres its icon, which is the + inset it needs. */ + padding-left: ${theme.padding}px; + padding-right: ${theme.sizeUnit}px; `} `; +/** + * 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 react-grid-layout drag. + * + * `data-block-remove` is the other half of that second one — `CanvasBlock` + * names it in `draggableCancel`, and react-grid-layout matches the selector 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; } @@ -141,6 +143,8 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( // ever raises an error. const chrome = nodeId !== provider.getRoot().id; const isRoot = !chrome; + // 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 ( @@ -244,27 +248,7 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( is not this button: the Outline selects any block with proper tree semantics and Properties carries the same Delete. */} {chrome && ( - <div - data-test={`block-header-${nodeId}`} - style={{ - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit, - height: headerHeight, - // Aligned with the inset every block's own content sits at, so - // a block's name reads as the head of the box beneath it rather - // than as something floating loose to its left. The right side - // stays tight: the remove button is a square target of its own - // and centres its icon, which is the inset it needs. - paddingLeft: theme.padding, - paddingRight: theme.sizeUnit, - // 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. - }} - > + <BlockHeader data-test={`block-header-${nodeId}`}> <Typography.Text ellipsis data-test={`block-title-${nodeId}`} @@ -282,27 +266,33 @@ const BuildingBlockView = forwardRef<HTMLDivElement, BuildingBlockViewProps>( > {blockLabel(node.type, node.props)} </Typography.Text> - <RemoveButton - type="button" + <RemoveSlot data-block-remove - data-test={`block-remove-${nodeId}`} - aria-label={t('Remove block')} - title={t('Remove block')} onMouseDown={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()} - onClick={event => { - event.stopPropagation(); - provider.removeBuildingBlock(nodeId); - }} + onClick={event => event.stopPropagation()} > - {/* 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. */} - <Icons.DeleteOutlined iconSize="s" /> - </RemoveButton> - </div> + <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> + </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 diff --git a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx index 46ec604cb4f2..2e44be78c988 100644 --- a/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx +++ b/superset-frontend/src/core/dashboard/blocks/CanvasBlock.test.tsx @@ -290,15 +290,20 @@ test('the grid is told not to start a drag from the remove control', () => { // react-grid-layout begins a drag on a press anywhere in the block it is // positioning, and the button sits inside that block. `draggableCancel` is // what it reads to exclude a region, so the selector and the attribute the - // button carries have to agree — aiming at the X would otherwise drag the + // control carries have to agree — aiming at the bin would otherwise drag the // block it is attached to. const cancel = screen .getByTestId('rgl') .getAttribute('data-draggable-cancel'); expect(cancel).toContain('[data-block-remove]'); - expect(screen.getByTestId(`block-remove-${first}`)).toHaveAttribute( - 'data-block-remove', - ); + // On the control or above it: react-draggable matches the selector against + // the pressed element and then walks its ancestors up to the grid item, so + // the attribute excludes the whole region it is set on. The bin is the + // shared `ActionButton`, which renders its own element and forwards no + // arbitrary attributes to it — the region is what carries this. + expect( + screen.getByTestId(`block-remove-${first}`).closest('[data-block-remove]'), + ).not.toBeNull(); }); test('clicking the remove control removes rather than selects', () => { diff --git a/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx index d161a53891b7..c18759652115 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/CanvasControls.tsx @@ -22,7 +22,7 @@ import { useTheme } from '@apache-superset/core/theme'; import { Button } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { provider } from 'src/core/dashboard/store'; -import Inert, { compact } from './InertControl'; +import Inert from './InertControl'; /** * What acts on the canvas as a whole, in the canvas's own corner. @@ -74,13 +74,12 @@ export default function CanvasControls(): ReactElement { }} > <Button - size="small" + buttonSize="xsmall" buttonStyle="link" aria-label={t('Arrange dashboard')} data-test="canvas-arrange" tooltip={t('Arrange dashboard — choose how the canvas lays blocks out')} placement="bottom" - style={{ ...compact(theme), paddingInline: theme.sizeUnit }} onClick={() => provider.setSelection(provider.getRoot().id)} > <Icons.LayoutOutlined iconSize="s" /> @@ -94,7 +93,7 @@ export default function CanvasControls(): ReactElement { // styles give a wrapped button a left margin meant for a row of them // — so this one sat further from its neighbour than the neighbour sat // from anything, and the pair's spacing stopped being the `gap` above. - style={{ paddingInline: theme.sizeUnit, marginLeft: 0 }} + style={{ marginLeft: 0 }} > <Icons.ReloadOutlined iconSize="s" /> </Inert> diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx index 27d6464de14a..96021ed09317 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardHeader.tsx @@ -20,8 +20,8 @@ import { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import { useSelector } from 'react-redux'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; -import { Input, PublishedLabel } from '@superset-ui/core/components'; +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'; @@ -89,6 +89,39 @@ const Metadata = (): ReactElement => { ); }; +/** + * 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. * @@ -108,16 +141,15 @@ const Metadata = (): ReactElement => { * tick per character for everything subscribed to the store. */ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { - const theme = useTheme(); 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 ( - <Input + <TitleInput size="small" - style={{ maxWidth: 220, height: theme.controlHeightSM }} + variant="borderless" value={draft} aria-label={t('Dashboard title')} placeholder={t('Untitled dashboard')} @@ -138,6 +170,37 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { ); }; +/** + * 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. * @@ -156,22 +219,10 @@ const Title = ({ nodeId, title }: { nodeId: string; title: string }) => { */ export default function DashboardHeader(): ReactElement { useDashboardRevision(); - const theme = useTheme(); const root = provider.getRoot(); return ( - <header - data-test="dashboard-header" - style={{ - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit * 2, - flex: '0 0 auto', - padding: theme.sizeUnit * 2, - borderBottom: `1px solid ${theme.colorBorder}`, - background: theme.colorBgContainer, - }} - > + <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 @@ -196,14 +247,7 @@ export default function DashboardHeader(): ReactElement { one question — what state is this in — and they are read together. */} <Metadata /> - <span - style={{ - marginLeft: 'auto', - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit * 2, - }} - > + <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. */} @@ -213,6 +257,11 @@ export default function DashboardHeader(): ReactElement { <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 @@ -223,7 +272,7 @@ export default function DashboardHeader(): ReactElement { <Inert label={t('Save')} test="header-save" reads> {t('Save')} </Inert> - </span> - </header> + </Actions> + </Bar> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx index d6fd3359d33b..803051557f6f 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/DashboardProperties.tsx @@ -77,15 +77,23 @@ const asString = (value: unknown): string => * * 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 ticks go with it — 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. + * 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.fontSizeSM, color: theme.colorTextSecondary }}> + <span + style={{ + fontSize: theme.fontSize, + fontWeight: theme.fontWeightStrong, + color: theme.colorText, + }} + > {title} </span> ); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx index 523bd2152cfa..aafe7c08f71f 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/EditorPanel.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { KeyboardEvent, PointerEvent, ReactElement } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; +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'; @@ -46,6 +46,84 @@ 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. * @@ -60,7 +138,6 @@ export default function EditorPanel({ onAdd: (type: string) => void; }): ReactElement { useDashboardRevision(); - const theme = useTheme(); 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. */ @@ -164,20 +241,9 @@ export default function EditorPanel({ */ if (closed) { return ( - <aside - data-test="editor-panel" - aria-label={t('Editor panel')} - style={{ - flexShrink: 0, - display: 'flex', - justifyContent: 'center', - padding: theme.sizeUnit, - borderRight: `1px solid ${theme.colorBorder}`, - background: theme.colorBgContainer, - }} - > + <ClosedRail data-test="editor-panel" aria-label={t('Editor panel')}> <Button - size="small" + buttonSize="xsmall" buttonStyle="link" data-test="panel-expand" aria-label={t('Show the editor panel')} @@ -185,35 +251,21 @@ export default function EditorPanel({ tooltip={t('Show the editor panel')} placement="right" onClick={() => setClosed(false)} - style={{ - height: theme.controlHeightSM, - paddingInline: theme.sizeUnit, - }} > <Icons.MenuUnfoldOutlined iconSize="m" /> </Button> - </aside> + </ClosedRail> ); } return ( - <aside + <Rail ref={panel} data-test="editor-panel" aria-label={t('Editor panel')} - style={{ - width, - flexShrink: 0, - display: 'flex', - flexDirection: '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, - borderRight: `1px solid ${theme.colorBorder}`, - background: theme.colorBgContainer, - }} + // 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} @@ -226,7 +278,7 @@ export default function EditorPanel({ tabBarExtraContent={{ right: ( <Button - size="small" + buttonSize="xsmall" buttonStyle="link" data-test="panel-collapse" aria-label={t('Hide the editor panel')} @@ -234,10 +286,6 @@ export default function EditorPanel({ tooltip={t('Hide the editor panel')} placement="bottom" onClick={() => setClosed(true)} - style={{ - height: theme.controlHeightSM, - paddingInline: theme.sizeUnit, - }} > <Icons.MenuFoldOutlined iconSize="m" /> </Button> @@ -264,7 +312,7 @@ export default function EditorPanel({ {/* 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. */} - <div + <Grip // eslint-disable-next-line jsx-a11y/prefer-tag-over-role role="separator" tabIndex={0} @@ -274,6 +322,7 @@ export default function EditorPanel({ aria-valuenow={width} aria-valuemin={MIN_WIDTH} aria-valuemax={MAX_WIDTH} + $active={gripped} onPointerDown={startDrag} onPointerMove={drag} onPointerUp={endDrag} @@ -282,24 +331,7 @@ export default function EditorPanel({ onPointerLeave={() => setGripped(from.current !== null)} onFocus={() => setGripped(true)} onBlur={() => setGripped(false)} - style={{ - 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, - zIndex: 1, - cursor: 'col-resize', - // The cursor alone only answers an author who already suspected the - // edge could move. Colouring it under the pointer — and on focus, - // where there is no cursor to read — is what says so first. - background: gripped ? theme.colorPrimaryBorder : 'transparent', - transition: `background ${theme.motionDurationMid}`, - touchAction: 'none', - }} /> - </aside> + </Rail> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx index 072372dcfa19..c94aec238ad4 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/InertControl.tsx @@ -18,36 +18,27 @@ */ import type { ReactElement, ReactNode } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; import { Button, type ButtonProps } from '@superset-ui/core/components'; const NOT_AVAILABLE = t('Not available yet'); /** - * This prototype's controls, at two sizes. + * This prototype's controls, at two of the shared Button's own sizes. * - * Nothing here is drawn at full size: these bars 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 the theme has is + * 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. * - * Both are driven off the theme's own control scale rather than literals, so - * they track the size the rest of the app is built on instead of drifting. + * 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. */ -export const named = (theme: ReturnType<typeof useTheme>) => ({ - height: theme.controlHeightSM, - paddingInline: theme.sizeUnit * 2.5, - fontSize: theme.fontSize, - lineHeight: 1, -}); - -export const compact = (theme: ReturnType<typeof useTheme>) => ({ - height: theme.controlHeightXS, - paddingInline: theme.sizeUnit * 1.5, - fontSize: theme.fontSizeSM, - lineHeight: 1, -}); /** * An affordance that is present, named and honest about not working. @@ -81,17 +72,16 @@ export default function Inert({ style?: ButtonProps['style']; children: ReactNode; }): ReactElement { - const theme = useTheme(); return ( <Button - size="small" + buttonSize={reads ? 'small' : 'xsmall'} buttonStyle={buttonStyle} disabled aria-label={label} data-test={test} tooltip={`${label} — ${NOT_AVAILABLE}`} placement="bottom" - style={{ ...(reads ? named(theme) : compact(theme)), ...style }} + style={style} > {children} </Button> diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx index 4d4d756d3f89..04eb6c8e0fec 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx @@ -20,9 +20,10 @@ 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 { useTheme } from '@apache-superset/core/theme'; +import { css, styled, useTheme } from '@apache-superset/core/theme'; import { Button, + EmptyState, Form, Input, InputNumber, @@ -33,6 +34,7 @@ import { 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 { resolveLayoutMode } from 'src/core/dashboard/layoutStyle'; import DashboardProperties from './DashboardProperties'; import LayoutModeSwitcher from './LayoutModeSwitcher'; @@ -194,20 +196,68 @@ const FlexFields = ({ }; /** - * The panel's own buttons, sized down. + * A group of fields, and where one stops. * - * Everything here acts on one block, in a rail whose width is spent on the - * fields rather than on the controls that commit them — and a panel of - * full-height buttons reads as a row of decisions before you have read what - * any of them apply to. Driven off the theme's smallest control step, the - * same one the header's icon controls sit at, so the two rails agree. + * 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 minor = (theme: ReturnType<typeof useTheme>) => ({ - height: theme.controlHeightXS, - paddingInline: theme.sizeUnit * 2, - fontSize: theme.fontSizeSM, - lineHeight: 1, -}); +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, @@ -217,23 +267,12 @@ const Section = ({ title: string; test: string; children: ReactNode; -}): ReactElement => { - const theme = useTheme(); - return ( - <section data-test={test} style={{ marginTop: theme.sizeUnit * 4 }}> - <h4 - style={{ - margin: `0 0 ${theme.sizeUnit * 2}px`, - fontSize: theme.fontSizeSM, - color: theme.colorTextSecondary, - }} - > - {title} - </h4> - {children} - </section> - ); -}; +}): ReactElement => ( + <Group data-test={test}> + <GroupTitle>{title}</GroupTitle> + {children} + </Group> +); /** * A number that may be absent, and stays absent when cleared. @@ -294,10 +333,10 @@ const ContentField = ({ useEffect(() => setDraft(content), [content, nodeId]); return ( - <Form.Item - label={t('Content')} - style={{ marginBottom: theme.sizeUnit * 2 }} - > + // "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} @@ -337,18 +376,23 @@ const StackingControls = ({ nodeId }: { nodeId: string }): ReactElement => { const theme = useTheme(); return ( <div style={{ display: 'flex', gap: theme.sizeUnit }}> + {/* `secondary`, which is what this app calls a button that is not the + headline action — the style Cancel takes beside Save. Left unsaid the + shared Button draws every one of them `primary`, which is two filled + headline buttons for what is a pair of nudges. Neither of these + commits anything, and neither leads. */} <Button - size="small" + buttonSize="xsmall" + buttonStyle="secondary" data-test="inspector-bring-to-front" - style={minor(theme)} onClick={() => provider.bringToFront(nodeId)} > {t('Bring to front')} </Button> <Button - size="small" + buttonSize="xsmall" + buttonStyle="secondary" data-test="inspector-send-to-back" - style={minor(theme)} onClick={() => provider.sendToBack(nodeId)} > {t('Send to back')} @@ -453,10 +497,9 @@ const PropsJsonEditor = ({ )} <div style={{ display: 'flex', gap: theme.sizeUnit }}> <Button - size="small" + buttonSize="xsmall" buttonStyle="primary" data-test="inspector-props-apply" - style={minor(theme)} disabled={parsed === undefined || !dirty} onClick={() => { if (parsed === undefined) { @@ -473,10 +516,13 @@ const PropsJsonEditor = ({ > {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 - size="small" + buttonSize="xsmall" + buttonStyle="secondary" data-test="inspector-props-revert" - style={minor(theme)} disabled={!dirty} onClick={() => setDraft(accepted)} > @@ -490,13 +536,13 @@ const PropsJsonEditor = ({ message, and a copy that says nothing leaves you pressing it again to be sure. */} <Button - size="small" + 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={{ ...minor(theme), marginLeft: 'auto' }} + style={{ marginLeft: 'auto' }} onClick={() => { copyTextToClipboard(() => Promise.resolve(draft)); setCopied(true); @@ -614,17 +660,16 @@ export default function Inspector(): ReactElement { if (!node) { return ( - <p - data-test="inspector-empty" - style={{ - ...inset, - margin: 0, - color: theme.colorTextTertiary, - fontSize: theme.fontSizeSM, - }} - > - {t('Select a block to edit its properties.')} - </p> + <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> ); } @@ -653,16 +698,18 @@ export default function Inspector(): ReactElement { {isRoot ? ( <DashboardProperties /> ) : ( - <p - data-test="inspector-identity" - style={{ - margin: 0, - color: theme.colorTextSecondary, - wordBreak: 'break-all', - }} - > - {node.type} · {node.id} - </p> + // 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 — @@ -690,23 +737,24 @@ export default function Inspector(): ReactElement { title={t('Arrangement')} test="inspector-section-arrangement" > + {/* One run of fields: the mode is asked as a field now, so it + spaces itself against the rest rather than needing a gap put + between it and them. */} <LayoutModeSwitcher nodeId={node.id} /> - <div style={{ marginTop: theme.sizeUnit * 3 }}> - {CONTAINER_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 }) - } - /> - ))} - {resolveLayoutMode(node.layout) === 'flex' && ( - <FlexFields nodeId={node.id} layout={node.layout} /> - )} - </div> + {CONTAINER_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 }) + } + /> + ))} + {resolveLayoutMode(node.layout) === 'flex' && ( + <FlexFields nodeId={node.id} layout={node.layout} /> + )} </Section> )} @@ -738,17 +786,30 @@ export default function Inspector(): ReactElement { </Form> {/* `removeBuildingBlock` refuses the root, so offering it here would be - a button that only ever raises. */} + 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 && ( - <Button - size="small" - danger - data-test="inspector-delete" - style={{ ...minor(theme), marginTop: theme.sizeUnit * 3 }} - onClick={() => provider.removeBuildingBlock(node.id)} - > - {t('Delete')} - </Button> + <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> ); diff --git a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx index 7f9a20a3fcf7..ae61f668827d 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/LayoutModeSwitcher.tsx @@ -19,8 +19,8 @@ import type { ReactElement } from 'react'; import type { dashboard as dashboardApi } from '@apache-superset/core'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; -import { Radio, Tooltip } from '@superset-ui/core/components'; +import { css, styled } from '@apache-superset/core/theme'; +import { Form, Radio, Tooltip } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { provider, useDashboardRevision } from 'src/core/dashboard/store'; import { resolveLayoutMode } from 'src/core/dashboard/layoutStyle'; @@ -61,6 +61,21 @@ const MODES: readonly { }, ]; +/** Spaced like the number fields it sits above, so the section reads evenly. */ +const ModeField = styled(Form.Item)` + ${({ theme }) => css` + margin-bottom: ${theme.sizeUnit * 2}px; + `} +`; + +const ModeLabel = styled.span` + ${({ theme }) => css` + display: inline-flex; + align-items: center; + gap: ${theme.sizeUnit}px; + `} +`; + /** * How one container arranges its children. * @@ -80,7 +95,6 @@ export default function LayoutModeSwitcher({ nodeId: string; }): ReactElement | null { useDashboardRevision(); - const theme = useTheme(); const node = provider.getNode(nodeId); if (!node?.children) { return null; @@ -88,27 +102,18 @@ export default function LayoutModeSwitcher({ const mode = resolveLayoutMode(node.layout); return ( - <div - data-test="layout-mode-switcher" - style={{ - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit * 2, - }} - > - <span - id={`layout-mode-label-${nodeId}`} - style={{ - fontSize: theme.fontSizeSM, - color: theme.colorTextTertiary, - }} - > - {t('Layout')} - </span> + // Asked the way every other field in this section is asked — label above, + // control beneath. Beside its control it was the one question in the + // Arrangement section reading left to right, which made a setting that + // belongs with the columns and the gap look like chrome sitting over them. + <ModeField label={t('Layout')} data-test="layout-mode-switcher"> <Radio.Group size="small" value={mode} - aria-labelledby={`layout-mode-label-${nodeId}`} + // The `Form.Item` label has no control id to point at without a + // `name`, so the group carries its own name rather than going + // unannounced. + aria-label={t('Layout')} onChange={event => provider.updateLayout(nodeId, { mode: event.target.value as LayoutMode, @@ -120,21 +125,17 @@ export default function LayoutModeSwitcher({ <Radio.Button value={option.key} data-test={`layout-mode-${option.key}`} - // Sized with the rest of the header rather than left at antd's - // small step: a control that stands taller than everything - // beside it reads as a different kind of thing. - style={{ - height: theme.controlHeightSM, - paddingInline: theme.sizeUnit * 2, - fontSize: theme.fontSizeSM, - lineHeight: `${theme.controlHeightSM - 2}px`, - }} > - {option.icon} {option.label} + {/* The icon and the name are one label, spaced by the layout + rather than by a text node holding a space. */} + <ModeLabel> + {option.icon} + {option.label} + </ModeLabel> </Radio.Button> </Tooltip> ))} </Radio.Group> - </div> + </ModeField> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx index 609d83228f37..2cd27aa0b953 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Outline.tsx @@ -18,7 +18,8 @@ */ import type { ReactElement } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; +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'; @@ -69,6 +70,140 @@ const select = (nodeId: string): void => { ?.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, @@ -76,7 +211,6 @@ const Row = ({ nodeId: string; depth: number; }): ReactElement | null => { - const theme = useTheme(); const node = provider.getNode(nodeId); if (!node) { return null; @@ -86,12 +220,13 @@ const Row = ({ return ( <li role="none"> - <div + <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 === ' ') { @@ -99,38 +234,22 @@ const Row = ({ select(nodeId); } }} - style={{ - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit, - padding: `${theme.sizeUnit / 2}px ${theme.sizeUnit}px`, - paddingLeft: theme.sizeUnit * (1 + depth * 3), - borderRadius: theme.borderRadius, - fontSize: theme.fontSizeSM, - color: selected ? theme.colorPrimaryText : theme.colorText, - background: selected ? theme.colorPrimaryBg : undefined, - cursor: 'pointer', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} > {labelOf(node.type, node.props)} - </div> + </OutlineTile> {children.length > 0 && ( - <ul + <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" - style={{ listStyle: 'none', margin: 0, padding: 0 }} > {children.map(childId => ( <Row key={childId} nodeId={childId} depth={depth + 1} /> ))} - </ul> + </Branch> )} </li> ); @@ -150,31 +269,31 @@ const Row = ({ */ export default function Outline(): ReactElement { useDashboardRevision(); - const theme = useTheme(); const root = provider.getRoot(); const children = root.children ?? []; if (children.length === 0) { return ( - <p - data-test="outline-empty" - style={{ color: theme.colorTextTertiary, fontSize: theme.fontSizeSM }} - > - {t('Nothing on the dashboard yet.')} - </p> + <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 ( - <ul - role="tree" - aria-label={t('Dashboard outline')} - data-test="outline" - style={{ listStyle: 'none', margin: 0, padding: 0 }} - > - {children.map(childId => ( - <Row key={childId} nodeId={childId} depth={0} /> - ))} - </ul> + <Panel> + <List role="tree" aria-label={t('Dashboard outline')} data-test="outline"> + {children.map(childId => ( + <Row key={childId} nodeId={childId} depth={0} /> + ))} + </List> + </Panel> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx index e9ccbf945763..79e69dfa3322 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/Palette.tsx @@ -19,8 +19,8 @@ import { useMemo, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; -import { Input } from '@superset-ui/core/components'; +import { css, styled } from '@apache-superset/core/theme'; +import { EmptyState, Input } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { views } from 'src/core/views'; import { DASHBOARD_BUILDING_BLOCKS_LOCATION } from 'src/core/dashboard/resolveBuildingBlockView'; @@ -77,6 +77,209 @@ const matches = (entry: PaletteEntry, query: string): boolean => { ); }; +/** + * 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. @@ -91,42 +294,28 @@ const Disclosure = ({ open: boolean; onToggle: () => void; children: ReactNode; -}): ReactElement => { - const theme = useTheme(); - return ( - <div data-test={`palette-shelf-${name.toLowerCase()}`}> - <button - type="button" - aria-expanded={open} - // The caret carries an `aria-label` of its own, which would otherwise - // join the shelf's name and announce the shape of the arrow first. - aria-label={name} - onClick={onToggle} - style={{ - display: 'flex', - alignItems: 'center', - gap: theme.sizeUnit, - width: '100%', - padding: `${theme.sizeUnit}px 0`, - border: 0, - background: 'none', - color: theme.colorTextSecondary, - fontSize: theme.fontSizeSM, - textAlign: 'left', - cursor: 'pointer', - }} - > +}): 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.CaretDownOutlined iconSize="s" /> + <Icons.MinusSquareOutlined iconSize="s" /> ) : ( - <Icons.CaretRightOutlined iconSize="s" /> + <Icons.PlusSquareOutlined iconSize="s" /> )} - {name} - </button> - {open && <div style={{ marginLeft: theme.sizeUnit * 3 }}>{children}</div>} - </div> - ); -}; + </span> + {name} + </ShelfButton> + {open && <Branch>{children}</Branch>} + </div> +); /** * The building blocks, as things to place. @@ -141,7 +330,6 @@ export default function Palette({ }: { onAdd: (type: string) => void; }): ReactElement { - const theme = useTheme(); const [query, setQuery] = useState(''); const [closed, setClosed] = useState<ReadonlySet<string>>(new Set()); @@ -159,102 +347,78 @@ export default function Palette({ }); return ( - <div - data-test="palette" - style={{ - display: 'flex', - flexDirection: 'column', - gap: theme.sizeUnit, - // 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`, - }} - > + <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 - size="small" allowClear value={query} aria-label={t('Search components')} placeholder={t('Search components…')} data-test="palette-search" prefix={<Icons.SearchOutlined iconSize="s" />} - style={{ marginBottom: theme.sizeUnit }} onChange={event => setQuery(event.target.value)} /> {found.length === 0 ? ( - <p - data-test="palette-empty" - style={{ - margin: `${theme.sizeUnit * 2}px 0 0`, - fontSize: theme.fontSizeSM, - color: theme.colorTextTertiary, - }} - > - {t('No building block matches “%s”.', query)} - </p> + <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.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 => ( - <button - 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 rows 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'; - }} - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: theme.sizeUnit, - width: '100%', - textAlign: 'left', - padding: `${theme.sizeUnit}px ${theme.sizeUnit * 2}px`, - marginBottom: theme.sizeUnit, - border: `1px solid ${theme.colorBorder}`, - borderRadius: theme.borderRadius, - background: theme.colorBgContainer, - color: theme.colorText, - fontSize: theme.fontSizeSM, - cursor: 'grab', - }} - > - {entry.label} - {/* Decoration: the row already carries the name, so - announcing the grip again would only repeat it. */} - <Icons.HolderOutlined - aria-hidden - iconSize="s" - iconColor={theme.colorTextTertiary} - /> - </button> - ))} - </Disclosure> - ); - }) + <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> )} - </div> + </Column> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx index 5cc7d7e87f39..a870b0e5df66 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/PropsForm.tsx @@ -19,13 +19,152 @@ import { useMemo } from 'react'; import type { ReactElement } from 'react'; import { t } from '@apache-superset/core/translation'; -import { useTheme } from '@apache-superset/core/theme'; +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. * @@ -77,7 +216,7 @@ export default function PropsForm({ ); return ( - <div + <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 @@ -128,6 +267,6 @@ export default function PropsForm({ untyped.join(', '), ), )} - </div> + </FormShell> ); } diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx index 25d03df42005..8c4515a38d37 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.test.tsx @@ -39,7 +39,9 @@ test('a blank dashboard can still be reached, and so can the layout it arranges // The canvas is no longer chat-only: a palette sits beside it, so the // empty state names both ways in. expect( - screen.getByText('Add a building block, or ask the assistant to start'), + screen.getByText( + 'Drag a building block from the panel, or ask the assistant for one.', + ), ).toBeInTheDocument(); // A `/dashboard/v2/new/` load lands on nothing, and that is exactly when diff --git a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx index 6f794484e49f..44dd0bbd61e5 100644 --- a/superset-frontend/src/pages/DashboardBuilderV2/index.tsx +++ b/superset-frontend/src/pages/DashboardBuilderV2/index.tsx @@ -19,8 +19,7 @@ import { useEffect } from 'react'; import { t } from '@apache-superset/core/translation'; import { css, styled } from '@apache-superset/core/theme'; -import { Flex, Typography } from '@superset-ui/core/components'; -import { Icons } from '@superset-ui/core/components/Icons'; +import { EmptyState, Flex } from '@superset-ui/core/components'; import { dashboard, useDashboardRevision } from 'src/core/dashboard'; import { provider } from 'src/core/dashboard/store'; import { placeBlock } from 'src/core/dashboard/placement'; @@ -71,13 +70,36 @@ const EmptyCanvasWrapper = styled.div` `} `; +/** + * The dashboard with nothing on it, as something to aim at. + * + * A dashed frame is what the rest of the app draws around a place a thing can + * be dropped, and this is one — the palette drags land here. It is also the + * only way to select the root on a blank canvas, so it answers a pointer and a + * Tab the way anything clickable does: the frame firms up and the surface + * lifts, rather than a dashed box that never reacts to being pressed. + */ const CanvasPlaceholder = styled(Flex)` ${({ theme }) => css` width: 100%; height: 100%; - border: 2px dashed ${theme.colorBorderSecondary}; + border: 2px dashed ${theme.colorBorder}; border-radius: ${theme.borderRadiusLG}px; color: ${theme.colorTextTertiary}; + cursor: pointer; + transition: + border-color ${theme.motionDurationMid}, + background-color ${theme.motionDurationMid}; + + &:hover { + border-color: ${theme.colorPrimaryBorderHover}; + background-color: ${theme.colorFillQuaternary}; + } + + &:focus-visible { + outline: 2px solid ${theme.colorPrimaryBorder}; + outline-offset: 2px; + } `} `; @@ -169,7 +191,6 @@ export default function DashboardBuilderV2() { vertical align="center" justify="center" - gap="small" // eslint-disable-next-line jsx-a11y/prefer-tag-over-role role="button" tabIndex={0} @@ -183,10 +204,13 @@ export default function DashboardBuilderV2() { } }} > - <Icons.AppstoreOutlined iconSize="xl" /> - <Typography.Text type="secondary"> - {t('Add a building block, or ask the assistant to start')} - </Typography.Text> + <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> ) : (