Line data Source code
1 : import * as React from 'react';
2 : import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type KeyboardCoordinateGetter } from '@dnd-kit/core';
3 :
4 : import { useLogger } from '../../platform/logger/logger_provider.js';
5 : import { ConnectionHealth, type ConnectionState } from '../../connections/connection_state.js';
6 : import { useCancelQuery, useQueryExecutor } from '../../connections/query_executor.js';
7 : import { runSessionScript } from '../scripts/rerun_query.js';
8 : import { useSessionExecution } from '../scripts/session_execution_provider.js';
9 : import { ACCEPT_PENDING_DIFF, DELETE_SCRIPT, REJECT_PENDING_DIFF, SET_SCRIPT_VIS_SPEC, type ScriptData, type SessionScripts } from '../scripts/session_scripts.js';
10 : import { renameScript, type RenameDraftReferences } from '../scripts/script_rename.js';
11 : import type { DashboardSpec } from '../scripts/script_types.js';
12 : import type { ModifySessionScripts } from '../scripts/session_scripts_registry.js';
13 : import { DashboardCard, type DashboardCardDependencies } from './dashboard_card.js';
14 : import { DashboardChartOverlay, DashboardEditorOverlay, DashboardTableOverlay, type DashboardOverlayState } from './dashboard_overlays.js';
15 : import {
16 : DASHBOARD_COLLAPSED_WIDTH,
17 : DASHBOARD_COLUMN_COUNT,
18 : DASHBOARD_GRID_GAP,
19 : DASHBOARD_ROW_HEIGHT,
20 : dashboardCardHeight,
21 : dashboardSectionRows,
22 : moveDashboardItem,
23 : placeDashboardItems,
24 : resizeDashboardItem,
25 : type DashboardPlacement,
26 : type DashboardSection,
27 : } from './dashboard_layout.js';
28 : import { persistDashboardPlacement, setDashboardRenderer, type DashboardRenderer } from './dashboard_source.js';
29 : import { SessionInputStrip } from '../inputs/session_input_strip.js';
30 : import { VerticalScrollShadows } from '../../ui/foundations/vertical_scroll_shadows.js';
31 : import * as styles from './dashboard.module.css';
32 :
33 1 : const GRID_GAP = DASHBOARD_GRID_GAP;
34 :
35 1 : function samePlacement(a: DashboardPlacement | undefined, b: DashboardPlacement): boolean {
36 1 : return a != null && a.id === b.id && a.row === b.row && a.column === b.column && a.width === b.width && a.height === b.height;
37 1 : }
38 :
39 : /// Keep the placements that did not change, the memoized cards only render again when their own placement changes.
40 : /// Returns the previous layout if nothing changed.
41 1 : function sharePlacements(next: DashboardPlacement[], previous: DashboardPlacement[]): DashboardPlacement[] {
42 1 : const previousById = new Map(previous.map(item => [item.id, item]));
43 1 : let changed = next.length !== previous.length;
44 1 : const shared = next.map((item, index) => {
45 1 : const before = previousById.get(item.id);
46 1 : if (!samePlacement(before, item)) {
47 1 : changed = true;
48 1 : return item;
49 1 : }
50 1 : if (previous[index] !== before) changed = true;
51 1 : return before!;
52 1 : });
53 1 : return changed ? shared : previous;
54 1 : }
55 :
56 : /// The functions and components that tests replace
57 : export interface DashboardDependencies {
58 : runSessionScript: typeof runSessionScript;
59 : DashboardEditorOverlay: typeof DashboardEditorOverlay;
60 : DashboardTableOverlay: typeof DashboardTableOverlay;
61 : DashboardChartOverlay: typeof DashboardChartOverlay;
62 : /// The dependencies of the cards, the defaults of the card if unset
63 : card?: DashboardCardDependencies;
64 : }
65 :
66 1 : const DEFAULT_DEPENDENCIES: DashboardDependencies = {
67 1 : runSessionScript,
68 1 : DashboardEditorOverlay,
69 1 : DashboardTableOverlay,
70 1 : DashboardChartOverlay,
71 1 : };
72 :
73 : interface Props {
74 : sessionScripts: SessionScripts;
75 : modifySessionScripts: ModifySessionScripts;
76 : /// The connections that the scripts run on, a card refreshes while its connection is online
77 : scriptConnections?: ReadonlyMap<string, ConnectionState>;
78 : /// The function that renames the references in the draft of the composer
79 : draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
80 : dependencies?: DashboardDependencies;
81 : }
82 :
83 : /// Every script of the session is a card, in the order of the session
84 1 : function readScripts(sessionScripts: SessionScripts): ScriptData[] {
85 1 : return sessionScripts.scriptOrder
86 1 : .map(fileName => sessionScripts.scripts[sessionScripts.scriptRefs[fileName].scriptId])
87 1 : .filter(script => script != null);
88 1 : }
89 :
90 : /// The placement that a script asks for: its visualization's, or the `@board` tag of a script without one
91 1 : function preferredPlacement(script: ScriptData): DashboardSpec | null {
92 1 : return script.annotations.visualizeQuery?.dashboardSpec ?? script.annotations.boardPosition ?? null;
93 1 : }
94 :
95 1 : function readLayout(sessionScripts: SessionScripts): DashboardPlacement[] {
96 1 : return placeDashboardItems(readScripts(sessionScripts).map(script => {
97 1 : const spec = preferredPlacement(script);
98 : // A script without a visualization is a collapsed card of one row, in the section of the scripts on top
99 1 : const collapsed = script.annotations.visualizeQuery == null;
100 1 : return {
101 1 : id: script.scriptKey,
102 1 : section: collapsed ? 'scripts' : 'visualizations',
103 1 : row: spec?.row,
104 1 : column: spec?.column,
105 1 : width: spec?.width ?? (collapsed ? DASHBOARD_COLLAPSED_WIDTH : undefined),
106 1 : height: collapsed ? 1 : spec?.height,
107 1 : };
108 1 : }));
109 1 : }
110 :
111 1 : export function Dashboard(props: Props): React.ReactElement {
112 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
113 1 : const gridRef = React.useRef<HTMLDivElement | null>(null);
114 1 : const scrollRef = React.useRef<HTMLDivElement | null>(null);
115 1 : const getScrollElement = React.useCallback(() => scrollRef.current, []);
116 1 : const resizeBaselineRef = React.useRef<DashboardPlacement[] | null>(null);
117 1 : const [layout, setLayout] = React.useState(() => readLayout(props.sessionScripts));
118 1 : const [gridWidth, setGridWidth] = React.useState(0);
119 1 : const [announcement, setAnnouncement] = React.useState('');
120 1 : const [overlay, setOverlay] = React.useState<DashboardOverlayState>(null);
121 1 : const logger = useLogger();
122 1 : const scripts = readScripts(props.sessionScripts);
123 1 : const layoutSourceKey = scripts.map(script => {
124 1 : const spec = preferredPlacement(script);
125 1 : const collapsed = script.annotations.visualizeQuery == null ? 'c' : '';
126 1 : return `${script.scriptKey}${collapsed}:${spec?.row ?? ''}:${spec?.column ?? ''}:${spec?.width ?? ''}:${spec?.height ?? ''}`;
127 1 : }).join('|');
128 :
129 1 : React.useEffect(() => setLayout(readLayout(props.sessionScripts)), [layoutSourceKey]);
130 1 : React.useEffect(() => {
131 1 : const grid = gridRef.current;
132 1 : if (grid == null) return;
133 1 : const observer = new ResizeObserver(entries => setGridWidth(entries[0]?.contentRect.width ?? grid.clientWidth));
134 1 : observer.observe(grid);
135 1 : setGridWidth(grid.clientWidth);
136 1 : return () => observer.disconnect();
137 1 : }, []);
138 :
139 1 : const columnStep = gridWidth > 0 ? (gridWidth - GRID_GAP * (DASHBOARD_COLUMN_COUNT - 1)) / DASHBOARD_COLUMN_COUNT + GRID_GAP : 1;
140 1 : const rowStep = DASHBOARD_ROW_HEIGHT + GRID_GAP;
141 1 : const keyboardCoordinates = React.useCallback<KeyboardCoordinateGetter>((event, { currentCoordinates }) => {
142 1 : switch (event.code) {
143 1 : case 'ArrowLeft': return { ...currentCoordinates, x: currentCoordinates.x - columnStep };
144 1 : case 'ArrowRight': return { ...currentCoordinates, x: currentCoordinates.x + columnStep };
145 1 : case 'ArrowUp': return { ...currentCoordinates, y: currentCoordinates.y - rowStep };
146 1 : case 'ArrowDown': return { ...currentCoordinates, y: currentCoordinates.y + rowStep };
147 1 : default: return undefined;
148 1 : }
149 1 : }, [columnStep, rowStep]);
150 1 : const sensors = useSensors(
151 1 : useSensor(PointerSensor),
152 1 : useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }),
153 1 : );
154 :
155 1 : const persistLayout = React.useCallback((next: DashboardPlacement[], previous: DashboardPlacement[]) => {
156 1 : const previousById = new Map(previous.map(item => [item.id, item]));
157 1 : for (const placement of next) {
158 1 : const before = previousById.get(placement.id);
159 1 : if (before?.row === placement.row && before.column === placement.column
160 1 : && before.width === placement.width && before.height === placement.height) continue;
161 1 : const script = props.sessionScripts.scripts[placement.id];
162 1 : if (script != null) persistDashboardPlacement(script, placement, props.modifySessionScripts);
163 1 : }
164 1 : }, [props.sessionScripts.scripts, props.modifySessionScripts]);
165 :
166 1 : const handleDragEnd = React.useCallback((event: DragEndEvent) => {
167 1 : const ownerId = Number(event.active.id);
168 1 : const current = layout.find(item => item.id === ownerId);
169 1 : if (current == null) return;
170 1 : const next = moveDashboardItem(
171 1 : layout,
172 1 : ownerId,
173 1 : current.row + Math.round(event.delta.y / rowStep),
174 1 : current.column + Math.round(event.delta.x / columnStep),
175 1 : );
176 1 : setLayout(next);
177 1 : persistLayout(next, layout);
178 1 : const placed = next.find(item => item.id === ownerId);
179 1 : const script = props.sessionScripts.scripts[ownerId];
180 1 : if (placed && script) {
181 1 : setAnnouncement(`${script.fileName} moved to row ${placed.row + 1}, column ${placed.column + 1}`);
182 : }
183 1 : }, [layout, columnStep, rowStep, persistLayout, props.sessionScripts.scripts]);
184 :
185 : // The resize handler reads the layout through a ref, so that the cards keep their callback while the layout changes
186 1 : const layoutRef = React.useRef(layout);
187 1 : layoutRef.current = layout;
188 1 : const handleResize = React.useCallback((ownerId: number, width: number, height: number, commit: boolean) => {
189 1 : const current = layoutRef.current;
190 1 : const baseline = resizeBaselineRef.current ?? current;
191 1 : if (!commit && resizeBaselineRef.current == null) resizeBaselineRef.current = current;
192 1 : const next = sharePlacements(resizeDashboardItem(current, ownerId, width, height), current);
193 : // Pointer moves within the snapped size do not change the layout
194 1 : if (!commit && next === current) return;
195 1 : setLayout(next);
196 1 : if (commit) {
197 1 : persistLayout(next, baseline);
198 1 : resizeBaselineRef.current = null;
199 1 : }
200 1 : }, [persistLayout]);
201 :
202 1 : const handleResizeCancel = React.useCallback(() => {
203 1 : if (resizeBaselineRef.current != null) setLayout(resizeBaselineRef.current);
204 1 : resizeBaselineRef.current = null;
205 1 : }, []);
206 :
207 : // The renderers of a card. A Markdown template and a bar chart are a start, the editor or the chart builder opens
208 : // to adjust them.
209 1 : const scriptsRef = React.useRef(props.sessionScripts.scripts);
210 1 : scriptsRef.current = props.sessionScripts.scripts;
211 1 : const handleVisualize = React.useCallback((scriptKey: number, renderer: DashboardRenderer, body: string) => {
212 1 : const script = scriptsRef.current[scriptKey];
213 1 : if (script == null) return;
214 1 : if (!setDashboardRenderer(script, renderer, body, props.modifySessionScripts, logger)) return;
215 1 : if (renderer === 'markdown') setOverlay({ scriptKey, kind: 'editor' });
216 1 : else if (renderer === 'vegalite') setOverlay({ scriptKey, kind: 'chart' });
217 1 : }, [props.modifySessionScripts, logger]);
218 1 : const handleRemoveVisualization = React.useCallback((scriptKey: number) => {
219 1 : const script = scriptsRef.current[scriptKey];
220 1 : if (script != null) setDashboardRenderer(script, null, '', props.modifySessionScripts, logger);
221 1 : }, [props.modifySessionScripts, logger]);
222 1 : const handleOpenChart = React.useCallback((scriptKey: number) => setOverlay({ scriptKey, kind: 'chart' }), []);
223 1 : const handleOpenEditor = React.useCallback((scriptKey: number) => setOverlay({ scriptKey, kind: 'editor' }), []);
224 1 : const handleOpenTable = React.useCallback((scriptKey: number) => setOverlay({ scriptKey, kind: 'table' }), []);
225 1 : const closeOverlay = React.useCallback(() => setOverlay(null), []);
226 : // Run a card like a script in the feed. A run may read cached results, a refresh runs the script and the scripts it
227 : // references without them.
228 1 : const executeQuery = useQueryExecutor();
229 1 : const cancelQuery = useCancelQuery();
230 1 : const sessionExecution = useSessionExecution(props.sessionScripts.sessionId);
231 1 : const sessionScriptsRef = React.useRef(props.sessionScripts);
232 1 : sessionScriptsRef.current = props.sessionScripts;
233 1 : const isOnline = (script: ScriptData) =>
234 1 : props.scriptConnections?.get(script.connection.connectionId)?.connectionHealth === ConnectionHealth.ONLINE;
235 1 : const handleRun = React.useCallback((scriptKey: number, refresh: boolean) => {
236 1 : const sessionScripts = sessionScriptsRef.current;
237 1 : const script = sessionScripts.scripts[scriptKey];
238 1 : if (script == null) return;
239 1 : void dependencies.runSessionScript(sessionScripts, script, executeQuery, props.modifySessionScripts, logger, { refresh });
240 1 : }, [dependencies, executeQuery, props.modifySessionScripts, logger]);
241 : // Stop the run of the session, or the query of the card
242 1 : const sessionBusy = sessionExecution.busy;
243 1 : const stopSession = sessionExecution.stop;
244 1 : const handleStop = React.useCallback((scriptKey: number) => {
245 1 : const script = sessionScriptsRef.current.scripts[scriptKey];
246 1 : if (sessionBusy) stopSession();
247 1 : else if (script?.latestQueryId != null) cancelQuery(script.connection.connectionId, script.latestQueryId);
248 1 : }, [sessionBusy, stopSession, cancelQuery]);
249 : // Accept or reject a staged rewrite of a card, like in the feed
250 1 : const handleAcceptDiff = React.useCallback((scriptKey: number) => {
251 1 : props.modifySessionScripts({ type: ACCEPT_PENDING_DIFF, value: scriptKey });
252 1 : }, [props.modifySessionScripts]);
253 1 : const handleRejectDiff = React.useCallback((scriptKey: number) => {
254 1 : props.modifySessionScripts({ type: REJECT_PENDING_DIFF, value: scriptKey });
255 1 : }, [props.modifySessionScripts]);
256 : // Rename and delete a card from its menu, like a script in the feed
257 1 : const draftRenameRef = props.draftRenameRef;
258 1 : const handleRename = React.useCallback((scriptKey: number, name: string) => {
259 1 : const sessionScripts = sessionScriptsRef.current;
260 1 : const script = sessionScripts.scripts[scriptKey];
261 1 : if (script == null) return;
262 1 : void renameScript(sessionScripts, props.modifySessionScripts, script.fileName, name, draftRenameRef?.current);
263 1 : }, [props.modifySessionScripts, draftRenameRef]);
264 1 : const handleDelete = React.useCallback((scriptKey: number) => {
265 1 : const script = sessionScriptsRef.current.scripts[scriptKey];
266 1 : if (script != null) props.modifySessionScripts({ type: DELETE_SCRIPT, value: script.fileName });
267 1 : }, [props.modifySessionScripts]);
268 : // The chart builder stages every edit of its spec as a diff of the script, like in the feed
269 1 : const handleSetVisSpec = React.useCallback((spec: string) => {
270 1 : if (overlay == null) return;
271 1 : props.modifySessionScripts({ type: SET_SCRIPT_VIS_SPEC, value: { scriptKey: overlay.scriptKey, spec } });
272 1 : }, [overlay, props.modifySessionScripts]);
273 1 : const overlayScript = overlay == null ? null : props.sessionScripts.scripts[overlay.scriptKey] ?? null;
274 :
275 1 : const placementById = new Map(layout.map(item => [item.id, item]));
276 1 : const scriptOrder = new Map(scripts.map((script, index) => [script.scriptKey, index]));
277 1 : const cardScripts = [...scripts].sort((left, right) => {
278 1 : const a = placementById.get(left.scriptKey);
279 1 : const b = placementById.get(right.scriptKey);
280 1 : if (a == null || b == null) return (scriptOrder.get(left.scriptKey) ?? 0) - (scriptOrder.get(right.scriptKey) ?? 0);
281 1 : return a.row - b.row || a.column - b.column
282 1 : || (scriptOrder.get(left.scriptKey) ?? 0) - (scriptOrder.get(right.scriptKey) ?? 0);
283 1 : });
284 1 : const renderSection = (section: DashboardSection, label: string) => {
285 1 : const sectionScripts = cardScripts.filter(script => placementById.get(script.scriptKey)?.section === section);
286 1 : if (sectionScripts.length === 0) return null;
287 1 : return (
288 1 : <div
289 1 : className={section === 'scripts' ? styles.grid_scripts : styles.grid}
290 1 : role="group"
291 1 : aria-label={label}
292 1 : style={{ minHeight: dashboardCardHeight(dashboardSectionRows(layout, section)) }}
293 : >
294 1 : {sectionScripts.map(script => {
295 1 : const placement = placementById.get(script.scriptKey)!;
296 1 : const preferred = preferredPlacement(script);
297 1 : const shifted = (preferred?.row != null && preferred.row !== placement.row)
298 1 : || (preferred?.column != null && preferred.column !== placement.column);
299 1 : return (
300 1 : <DashboardCard
301 1 : key={script.scriptKey}
302 1 : sessionId={props.sessionScripts.sessionId}
303 1 : script={script}
304 1 : placement={placement}
305 1 : columnStep={columnStep}
306 1 : rowStep={rowStep}
307 1 : onResize={handleResize}
308 1 : onResizeCancel={handleResizeCancel}
309 1 : shifted={shifted}
310 1 : onVisualize={handleVisualize}
311 1 : onOpenChart={handleOpenChart}
312 1 : onRemoveVisualization={handleRemoveVisualization}
313 1 : onOpenEditor={handleOpenEditor}
314 1 : onOpenTable={handleOpenTable}
315 1 : onAcceptDiff={handleAcceptDiff}
316 1 : onRejectDiff={handleRejectDiff}
317 1 : runnable={!sessionExecution.runActive && isOnline(script)}
318 1 : onRun={handleRun}
319 1 : onStop={handleStop}
320 1 : onRename={handleRename}
321 1 : onDelete={handleDelete}
322 1 : dependencies={dependencies.card}
323 1 : />
324 : );
325 1 : })}
326 1 : </div>
327 : );
328 1 : };
329 1 : return (
330 1 : <section className={styles.page} aria-label="Dashboard">
331 1 : <div className={styles.sr_only} role="status" aria-live="polite">{announcement}</div>
332 : {/* The inputs of the session sit on a bar above the scrolling cards */}
333 1 : <SessionInputStrip
334 1 : className={styles.inputs}
335 1 : sessionScripts={props.sessionScripts}
336 1 : modifySessionScripts={props.modifySessionScripts}
337 1 : />
338 : {/* The cards scroll under the bar of the inputs, the shadows mark the edges like in the feed */}
339 1 : <div className={styles.scroll_container}>
340 1 : <div ref={scrollRef} className={styles.scroll}>
341 1 : {scripts.length === 0 ? (
342 1 : <div className={styles.empty_state}>
343 1 : <strong>No scripts yet</strong>
344 1 : <span>Every script of the session appears here, queries with @vis comments as visualizations.</span>
345 1 : </div>
346 : ) : (
347 1 : <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
348 : {/* The scripts without a renderer on top, the visualizations below. Both sections share the columns. */}
349 1 : <div ref={gridRef} className={styles.sections}>
350 1 : {renderSection('scripts', 'Scripts without visualization')}
351 1 : {renderSection('visualizations', 'Visualizations')}
352 1 : </div>
353 1 : </DndContext>
354 : )}
355 1 : </div>
356 1 : <VerticalScrollShadows
357 1 : getScrollElement={getScrollElement}
358 1 : prominent
359 1 : refreshKey={`${props.sessionScripts.sessionId}:${scripts.length}:${dashboardSectionRows(layout, 'scripts')}:${dashboardSectionRows(layout, 'visualizations')}`}
360 1 : />
361 1 : </div>
362 1 : {overlayScript != null && overlay?.kind === 'editor' && (
363 1 : <dependencies.DashboardEditorOverlay
364 1 : sessionId={props.sessionScripts.sessionId}
365 1 : script={overlayScript}
366 1 : onAcceptDiff={handleAcceptDiff}
367 1 : onRejectDiff={handleRejectDiff}
368 1 : onClose={closeOverlay}
369 1 : />
370 : )}
371 1 : {overlayScript != null && overlay?.kind === 'table' && (
372 1 : <dependencies.DashboardTableOverlay sessionId={props.sessionScripts.sessionId} script={overlayScript} onClose={closeOverlay} />
373 : )}
374 1 : {overlayScript != null && overlay?.kind === 'chart' && (
375 1 : <dependencies.DashboardChartOverlay
376 1 : sessionId={props.sessionScripts.sessionId}
377 1 : script={overlayScript}
378 1 : onChange={handleSetVisSpec}
379 1 : onClose={closeOverlay}
380 1 : />
381 : )}
382 1 : </section>
383 : );
384 1 : }
|