LCOV - code coverage report
Current view: top level - dashql-app/src/session/dashboard - dashboard.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 289 289
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1