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

            Line data    Source code
       1              : import * as React from 'react';
       2              : import { useDraggable } from '@dnd-kit/core';
       3              : import { CSS } from '@dnd-kit/utilities';
       4              : 
       5              : import { QueryResultPanel } from '../../views/query_result/query_result_header.js';
       6              : import { VisualizationDispatch } from '../../views/visualization/visualization_dispatch.js';
       7              : import { useQueryState } from '../../connections/query_executor.js';
       8              : import type { ScriptData } from '../scripts/session_scripts.js';
       9              : import { scriptDisplayName, type ResolvedVisualizeQuery } from '../scripts/script_types.js';
      10              : import { ScriptEditor } from '../scripts/script_editor.js';
      11              : import { QueryExecutionStatus, queryIsDone } from '../../query/query_execution_state.js';
      12              : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
      13              : import { ButtonGroup } from '../../ui/foundations/button_group.js';
      14              : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
      15              : import { CheckIcon, EyeClosedIcon, GraphIcon, PaperAirplaneIcon, PencilIcon, SquareFillIcon, SymbolIcon, SyncIcon, TableIcon, ToolsIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
      16              : import * as ActionList from '../../ui/foundations/action_list.js';
      17              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
      18              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
      19              : import { NameInput } from '../../ui/foundations/name_input.js';
      20              : import * as menuStyles from '../../app/action_menu.module.css';
      21              : import { dashboardCardHeight, type DashboardPlacement } from './dashboard_layout.js';
      22              : import { NEW_CHART_SPEC } from '../chart_builder/chart_spec_paths.js';
      23              : import { markdownTemplate, type DashboardRenderer } from './dashboard_source.js';
      24              : import * as styles from './dashboard.module.css';
      25              : 
      26            1 : const DragHandleIcon = SymbolIcon('drag_handle_16');
      27            1 : const MarkdownIcon = SymbolIcon('mark_text_16');
      28            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
      29            1 : const RenameIcon = SymbolIcon('pencil_16');
      30            1 : const TrashIcon = SymbolIcon('trash_16');
      31              : 
      32              : export interface DashboardCardDependencies {
      33              :     useQueryState: typeof useQueryState;
      34              :     QueryResultPanel: typeof QueryResultPanel;
      35              :     VisualizationDispatch: typeof VisualizationDispatch;
      36              :     ScriptEditor: typeof ScriptEditor;
      37              : }
      38              : 
      39            1 : const DEFAULT_DEPENDENCIES: DashboardCardDependencies = {
      40            1 :     useQueryState,
      41            1 :     QueryResultPanel,
      42            1 :     VisualizationDispatch,
      43            1 :     ScriptEditor,
      44            1 : };
      45              : 
      46              : interface Props {
      47              :     sessionId: string;
      48              :     script: ScriptData;
      49              :     placement: DashboardPlacement;
      50              :     columnStep: number;
      51              :     rowStep: number;
      52              :     onResize: (scriptId: number, width: number, height: number, commit: boolean) => void;
      53              :     onResizeCancel: () => void;
      54              :     shifted: boolean;
      55              :     /// Give a card a renderer with a starter body
      56              :     onVisualize: (scriptId: number, renderer: DashboardRenderer, body: string) => void;
      57              :     /// Open the chart builder of a chart card
      58              :     onOpenChart: (scriptId: number) => void;
      59              :     /// Remove the renderer of a card, it collapses to its script
      60              :     onRemoveVisualization: (scriptId: number) => void;
      61              :     onOpenEditor: (scriptId: number) => void;
      62              :     /// Open the full result table of a table card
      63              :     onOpenTable: (scriptId: number) => void;
      64              :     /// Accept or reject the staged rewrite of the script
      65              :     onAcceptDiff: (scriptId: number) => void;
      66              :     onRejectDiff: (scriptId: number) => void;
      67              :     /// Can the script run? Its connection is online and no other run holds the session.
      68              :     runnable: boolean;
      69              :     /// Run the script, a refresh runs it and the scripts it references without cached results
      70              :     onRun: (scriptId: number, refresh: boolean) => void;
      71              :     /// Stop the running query of the script
      72              :     onStop: (scriptId: number) => void;
      73              :     /// Rename the script, the name is the new display name. The menu of the card offers it.
      74              :     onRename?: (scriptId: number, name: string) => void;
      75              :     /// Delete the script, the menu of the card offers it
      76              :     onDelete?: (scriptId: number) => void;
      77              :     dependencies?: DashboardCardDependencies;
      78              : }
      79              : 
      80              : /// Memoized, a resize of one card only renders the cards whose placement changed.
      81              : /// A script without a visualization is a collapsed card: a header of one row with the renderers and an editor.
      82            1 : export const DashboardCard = React.memo(function DashboardCard(props: Props): React.ReactElement {
      83            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
      84            1 :     const query = dependencies.useQueryState(props.sessionId, props.script.latestQueryId);
      85            1 :     const visualizeQuery: ResolvedVisualizeQuery | null = props.script.annotations.visualizeQuery;
      86            1 :     const collapsed = visualizeQuery == null;
      87              :     // A staged rewrite, e.g. of the renderer, shows on the card and waits to be accepted or rejected like in the feed
      88            1 :     const staged = props.script.pendingDiff != null;
      89            1 :     const displayName = scriptDisplayName(props.script.fileName);
      90            1 :     const resizeStart = React.useRef<{ x: number; y: number; width: number; height: number; pointerId: number } | null>(null);
      91            1 :     const draggable = useDraggable({ id: props.script.scriptKey });
      92              :     // Is the name being edited in the title of the card? Rename in the menu of the card starts it
      93            1 :     const [renaming, setRenaming] = React.useState(false);
      94            1 :     const renameInputRef = React.useRef<HTMLInputElement>(null);
      95            1 :     React.useEffect(() => {
      96            1 :         if (!renaming) return;
      97              :         // Focus after the frame, a closing menu returns the focus to its trigger first
      98            1 :         const frame = requestAnimationFrame(() => {
      99            1 :             renameInputRef.current?.focus();
     100            1 :             renameInputRef.current?.select();
     101            1 :         });
     102            1 :         return () => cancelAnimationFrame(frame);
     103            1 :     }, [renaming]);
     104            1 :     const onRename = props.onRename;
     105            1 :     const scriptKey = props.script.scriptKey;
     106            1 :     const commitRename = React.useCallback((name: string) => {
     107            1 :         setRenaming(false);
     108            1 :         if (name.trim() !== '' && name.trim() !== displayName) onRename?.(scriptKey, name.trim());
     109            1 :     }, [onRename, scriptKey, displayName]);
     110            1 :     const cancelRename = React.useCallback(() => setRenaming(false), []);
     111            1 :     const transform = draggable.transform == null ? undefined : CSS.Translate.toString(draggable.transform);
     112              : 
     113            1 :     const resizeFromPointer = React.useCallback((event: PointerEvent, commit: boolean) => {
     114            1 :         const start = resizeStart.current;
     115            1 :         if (start == null || event.pointerId !== start.pointerId) return;
     116            1 :         const width = start.width + Math.round((event.clientX - start.x) / Math.max(1, props.columnStep));
     117              :         // A collapsed card is one row high, it only changes its width
     118            1 :         const height = collapsed ? start.height : start.height + Math.round((event.clientY - start.y) / Math.max(1, props.rowStep));
     119            1 :         props.onResize(props.script.scriptKey, width, height, commit);
     120            1 :         if (commit) resizeStart.current = null;
     121            1 :     }, [props.columnStep, props.rowStep, props.onResize, props.script.scriptKey, collapsed]);
     122              : 
     123            1 :     const startResize = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
     124            1 :         event.preventDefault();
     125            1 :         event.stopPropagation();
     126            1 :         resizeStart.current = {
     127            1 :             x: event.clientX,
     128            1 :             y: event.clientY,
     129            1 :             width: props.placement.width,
     130            1 :             height: props.placement.height,
     131            1 :             pointerId: event.pointerId,
     132            1 :         };
     133            1 :         const move = (next: PointerEvent) => resizeFromPointer(next, false);
     134            1 :         const finish = (next: PointerEvent) => {
     135            1 :             resizeFromPointer(next, true);
     136            1 :             cleanup();
     137            1 :         };
     138            1 :         const cancel = () => {
     139            1 :             resizeStart.current = null;
     140            1 :             props.onResizeCancel();
     141            1 :             cleanup();
     142            1 :         };
     143            1 :         const cleanup = () => {
     144            1 :             window.removeEventListener('pointermove', move);
     145            1 :             window.removeEventListener('pointerup', finish);
     146            1 :             window.removeEventListener('pointercancel', cancel);
     147            1 :         };
     148            1 :         window.addEventListener('pointermove', move);
     149            1 :         window.addEventListener('pointerup', finish);
     150            1 :         window.addEventListener('pointercancel', cancel);
     151            1 :     }, [props.placement.width, props.placement.height, props.onResizeCancel, resizeFromPointer]);
     152              : 
     153            1 :     const resizeWithKeyboard = React.useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
     154            1 :         let width = props.placement.width;
     155            1 :         let height = props.placement.height;
     156            1 :         if (event.key === 'ArrowLeft') width -= 1;
     157            1 :         else if (event.key === 'ArrowRight') width += 1;
     158            1 :         else if (event.key === 'ArrowUp' && !collapsed) height -= 1;
     159            1 :         else if (event.key === 'ArrowDown' && !collapsed) height += 1;
     160            1 :         else return;
     161            1 :         event.preventDefault();
     162            1 :         props.onResize(props.script.scriptKey, width, height, true);
     163            1 :     }, [props.placement, props.onResize, props.script.scriptKey, collapsed]);
     164              : 
     165              :     // The Markdown template starts with the columns of the latest result
     166            1 :     const resultColumns = query?.status === QueryExecutionStatus.SUCCEEDED
     167            1 :         ? query.resultTable?.schema.fields.map(field => field.name) ?? []
     168            1 :         : [];
     169            1 :     const template = markdownTemplate(resultColumns);
     170              :     // A visualized card shows a query, the tag can be on a statement before the last one
     171            1 :     const visualizable = visualizeQuery != null || props.script.annotations.lastStatementIsQuery === true;
     172            1 :     const notVisualizable = 'Only a query is visualized';
     173              :     // One button runs the card: a card without a result runs and may read cached results, a card with a result
     174              :     // refreshes without them, a running card stops
     175            1 :     const running = query != null && !queryIsDone(query.status);
     176            1 :     const hasResult = query?.status === QueryExecutionStatus.SUCCEEDED && query.cacheDeleted !== true;
     177            1 :     const runAction = running
     178            1 :         ? { label: `Stop ${displayName}`, description: 'Stop the query', icon: <SquareFillIcon size={10} />, onClick: () => props.onStop(props.script.scriptKey) }
     179            1 :         : hasResult
     180            1 :             ? { label: `Refresh ${displayName}`, description: 'Refresh, skips cached results', icon: <SyncIcon size={12} />, onClick: () => props.onRun(props.script.scriptKey, true) }
     181            1 :             : { label: `Run ${displayName}`, description: 'Run, may use cached results', icon: <PaperAirplaneIcon size={12} />, onClick: () => props.onRun(props.script.scriptKey, false) };
     182            1 :     const markdownDescription = template == null ? 'Run the script first' : 'Render every row as Markdown';
     183              : 
     184              :     // The renderers of a card in the order of its segments, the last one collapses the card to its script. A chart
     185              :     // starts as a bar chart, the board opens its chart builder.
     186            1 :     const renderers: (DashboardRenderer | null)[] = ['table', 'vegalite', 'markdown', null];
     187            1 :     const currentRenderer = visualizeQuery?.renderer ?? null;
     188            1 :     const switchRenderer = (index: number) => {
     189            1 :         const renderer = renderers[index];
     190            1 :         if (renderer === currentRenderer) return;
     191            1 :         if (renderer == null) props.onRemoveVisualization(props.script.scriptKey);
     192            1 :         else if (renderer === 'vegalite') props.onVisualize(props.script.scriptKey, 'vegalite', JSON.stringify(NEW_CHART_SPEC));
     193            1 :         else if (renderer === 'table') props.onVisualize(props.script.scriptKey, 'table', '');
     194            1 :         else if (template != null) props.onVisualize(props.script.scriptKey, 'markdown', template);
     195            1 :     };
     196              : 
     197            1 :     return (
     198            1 :         <article
     199            1 :             ref={draggable.setNodeRef}
     200            1 :             className={`${styles.card} ${collapsed ? styles.card_collapsed : ''}`}
     201            1 :             data-dashboard-card={props.script.scriptKey}
     202            1 :             style={{
     203            1 :                 gridColumn: `${props.placement.column + 1} / span ${props.placement.width}`,
     204            1 :                 gridRow: `${props.placement.row + 1} / span ${props.placement.height}`,
     205            1 :                 transform,
     206            1 :                 zIndex: draggable.isDragging ? 2 : undefined,
     207            1 :                 '--dashboard-card-rows': props.placement.height,
     208            1 :                 '--dashboard-card-height': `${dashboardCardHeight(props.placement.height)}px`,
     209            1 :             } as React.CSSProperties}
     210              :         >
     211            1 :             <header className={styles.card_header}>
     212            1 :                 <button
     213            1 :                     type="button"
     214            1 :                     className={styles.drag_handle}
     215            1 :                     aria-label={`Move ${displayName}`}
     216            1 :                     {...draggable.listeners}
     217            1 :                     {...draggable.attributes}
     218              :                 >
     219            1 :                     <DragHandleIcon />
     220            1 :                 </button>
     221            1 :                 {renaming ? (
     222            1 :                     <NameInput
     223            1 :                         ref={renameInputRef}
     224            1 :                         className={styles.card_title_input}
     225            1 :                         name={displayName}
     226            1 :                         onCommit={commitRename}
     227            1 :                         onCancel={cancelRename}
     228            1 :                         ariaLabel="Script name"
     229            1 :                         placeholder="Name this script"
     230            1 :                     />
     231              :                 ) : (
     232            1 :                     <h2 className={styles.card_title}>{displayName}</h2>
     233              :                 )}
     234            1 :                 {props.shifted && <span className={styles.shifted_badge} title="Moved to avoid overlapping another dashboard card">Shifted</span>}
     235            1 :                 {staged ? (
     236            1 :                     <>
     237              :                         {/* The script editor shows the change, accept and reject decide it */}
     238            1 :                         <ButtonGroup className={styles.card_actions} aria-label={`Change ${displayName}`}>
     239            1 :                             <IconButton
     240            1 :                                 size={ButtonSize.Small}
     241            1 :                                 variant={ButtonVariant.Default}
     242            1 :                                 aria-label={`Edit ${displayName}`}
     243            1 :                                 description="Show the change in the script"
     244            1 :                                 data-testid="dashboard-card-edit"
     245            1 :                                 onClick={() => props.onOpenEditor(props.script.scriptKey)}
     246              :                             >
     247            1 :                                 <PencilIcon size={12} />
     248            1 :                             </IconButton>
     249            1 :                         </ButtonGroup>
     250            1 :                         <ButtonGroup className={styles.card_actions} aria-label={`Accept or reject the change of ${displayName}`}>
     251            1 :                             <IconButton
     252            1 :                                 size={ButtonSize.Small}
     253            1 :                                 variant={ButtonVariant.Default}
     254            1 :                                 aria-label="Accept change"
     255            1 :                                 description="Keep the change of the script"
     256            1 :                                 data-testid="dashboard-card-accept"
     257            1 :                                 onClick={() => props.onAcceptDiff(props.script.scriptKey)}
     258              :                             >
     259            1 :                                 <CheckIcon size={12} />
     260            1 :                             </IconButton>
     261            1 :                             <IconButton
     262            1 :                                 size={ButtonSize.Small}
     263            1 :                                 variant={ButtonVariant.Default}
     264            1 :                                 aria-label="Reject change"
     265            1 :                                 description="Restore the script"
     266            1 :                                 data-testid="dashboard-card-reject"
     267            1 :                                 onClick={() => props.onRejectDiff(props.script.scriptKey)}
     268              :                             >
     269            1 :                                 <XIcon size={12} />
     270            1 :                             </IconButton>
     271            1 :                         </ButtonGroup>
     272            1 :                     </>
     273              :                 ) : (
     274            1 :                     <>
     275              :                         {/* The immediate actions, the editors open in an overlay of the board since a small card has no room
     276              :                             for them. The renderers follow at the right edge. */}
     277            1 :                         <ButtonGroup className={styles.card_actions} aria-label={`Actions of ${displayName}`}>
     278            1 :                             <IconButton
     279            1 :                                 size={ButtonSize.Small}
     280            1 :                                 variant={ButtonVariant.Default}
     281            1 :                                 aria-label={runAction.label}
     282            1 :                                 description={runAction.description}
     283            1 :                                 data-testid="dashboard-card-run"
     284            1 :                                 data-run-action={running ? 'stop' : hasResult ? 'refresh' : 'run'}
     285            1 :                                 disabled={!running && !props.runnable}
     286            1 :                                 onClick={runAction.onClick}
     287              :                             >
     288            1 :                                 {runAction.icon}
     289            1 :                             </IconButton>
     290            1 :                             {currentRenderer === 'vegalite' && (
     291            1 :                                 <IconButton
     292            1 :                                     size={ButtonSize.Small}
     293            1 :                                     variant={ButtonVariant.Default}
     294            1 :                                     aria-label="Chart builder"
     295            1 :                                     description="Build the chart"
     296            1 :                                     data-testid="dashboard-card-chart-builder"
     297            1 :                                     onClick={() => props.onOpenChart(props.script.scriptKey)}
     298              :                                 >
     299            1 :                                     <ToolsIcon size={12} />
     300            1 :                                 </IconButton>
     301              :                             )}
     302            1 :                             <IconButton
     303            1 :                                 size={ButtonSize.Small}
     304            1 :                                 variant={ButtonVariant.Default}
     305            1 :                                 aria-label={`Edit ${displayName}`}
     306            1 :                                 description="Edit the script"
     307            1 :                                 data-testid="dashboard-card-edit"
     308            1 :                                 onClick={() => props.onOpenEditor(props.script.scriptKey)}
     309              :                             >
     310            1 :                                 <PencilIcon size={12} />
     311            1 :                             </IconButton>
     312            1 :                             {(props.onRename != null || props.onDelete != null) && (
     313            1 :                                 <DashboardCardMenu
     314            1 :                                     scriptName={displayName}
     315            1 :                                     onRename={props.onRename != null ? () => setRenaming(true) : undefined}
     316            1 :                                     onDelete={props.onDelete != null ? () => props.onDelete!(props.script.scriptKey) : undefined}
     317            1 :                                 />
     318              :                             )}
     319            1 :                         </ButtonGroup>
     320            1 :                         <SegmentedControl
     321            1 :                             aria-label={`Renderer of ${displayName}`}
     322            1 :                             className={styles.card_segments}
     323            1 :                             size={SegmentedControlSize.Tiny}
     324            1 :                             onChange={switchRenderer}
     325              :                         >
     326            1 :                             <SegmentedControl.IconButton
     327            1 :                                 aria-label="Table"
     328            1 :                                 description={visualizable ? 'Show the result table' : notVisualizable}
     329            1 :                                 icon={<TableIcon size={12} />}
     330            1 :                                 data-testid="dashboard-card-renderer-table"
     331            1 :                                 disabled={!visualizable && currentRenderer !== 'table'}
     332            1 :                                 selected={currentRenderer === 'table'}
     333            1 :                             />
     334            1 :                             <SegmentedControl.IconButton
     335            1 :                                 aria-label="Chart"
     336            1 :                                 description={visualizable ? 'Build a chart' : notVisualizable}
     337            1 :                                 icon={<GraphIcon size={12} />}
     338            1 :                                 data-testid="dashboard-card-renderer-chart"
     339            1 :                                 disabled={!visualizable && currentRenderer !== 'vegalite'}
     340            1 :                                 selected={currentRenderer === 'vegalite'}
     341            1 :                             />
     342            1 :                             <SegmentedControl.IconButton
     343            1 :                                 aria-label="Markdown"
     344            1 :                                 description={visualizable ? markdownDescription : notVisualizable}
     345            1 :                                 icon={<MarkdownIcon size={12} />}
     346            1 :                                 data-testid="dashboard-card-renderer-markdown"
     347            1 :                                 disabled={(!visualizable || template == null) && currentRenderer !== 'markdown'}
     348            1 :                                 selected={currentRenderer === 'markdown'}
     349            1 :                             />
     350            1 :                             <SegmentedControl.IconButton
     351            1 :                                 aria-label="Collapsed"
     352            1 :                                 description="Show only the script, without a visualization"
     353            1 :                                 icon={<EyeClosedIcon size={12} />}
     354            1 :                                 data-testid="dashboard-card-renderer-collapsed"
     355            1 :                                 selected={currentRenderer == null}
     356            1 :                             />
     357            1 :                         </SegmentedControl>
     358            1 :                     </>
     359              :                 )}
     360            1 :             </header>
     361            1 :             {!collapsed && <div className={styles.card_body}>
     362            1 :                 {visualizeQuery.renderer === 'table' ? (
     363              :                     // A table shows the header of the result over the rows that fit, a chart only the chart
     364            1 :                     <dependencies.QueryResultPanel query={query} onShowTable={() => props.onOpenTable(props.script.scriptKey)} />
     365              :                 ) : (
     366            1 :                     <dependencies.VisualizationDispatch
     367            1 :                         query={query}
     368            1 :                         visualizeQuery={visualizeQuery}
     369            1 :                         hideLegend={props.placement.width < 5}
     370            1 :                     />
     371              :                 )}
     372            1 :             </div>}
     373            1 :             <button
     374            1 :                 type="button"
     375            1 :                 className={styles.resize_handle}
     376            1 :                 aria-label={collapsed
     377            1 :                     ? `Resize ${displayName}. Current width ${props.placement.width} columns`
     378            1 :                     : `Resize ${displayName}. Current size ${props.placement.width} columns by ${props.placement.height} rows`}
     379            1 :                 onPointerDown={startResize}
     380            1 :                 onKeyDown={resizeWithKeyboard}
     381              :             >
     382            1 :                 <svg width="8" height="8" viewBox="0 0 8 8" aria-hidden="true">
     383            1 :                     <path d="M7 1V7H1Z" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
     384            1 :                 </svg>
     385            1 :             </button>
     386            1 :         </article>
     387              :     );
     388            1 : });
     389              : 
     390              : /// The menu of a card with the actions that change the script itself
     391            1 : function DashboardCardMenu(props: { scriptName: string; onRename?: () => void; onDelete?: () => void }): React.ReactElement {
     392            1 :     const [open, setOpen] = React.useState(false);
     393            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     394            1 :     const run = (action: () => void) => {
     395            1 :         setOpen(false);
     396            1 :         action();
     397            1 :     };
     398            1 :     return (
     399            1 :         <AnchoredOverlay
     400            1 :             open={open}
     401            1 :             onOpen={() => setOpen(true)}
     402            1 :             onClose={() => setOpen(false)}
     403            1 :             side={AnchorSide.OutsideBottom}
     404            1 :             align={AnchorAlignment.End}
     405            1 :             anchorOffset={4}
     406            1 :             anchorRef={triggerRef}
     407            1 :             returnFocusRef={triggerRef}
     408            1 :             renderAnchor={anchorProps => (
     409            1 :                 <IconButton
     410            1 :                     {...anchorProps}
     411            1 :                     ref={triggerRef}
     412            1 :                     size={ButtonSize.Small}
     413            1 :                     variant={ButtonVariant.Default}
     414            1 :                     aria-label={`More actions for ${props.scriptName}`}
     415            1 :                     data-testid="dashboard-card-more"
     416              :                 >
     417            1 :                     <MoreIcon size={12} />
     418            1 :                 </IconButton>
     419              :             )}
     420              :         >
     421            1 :             <div className={menuStyles.menu} role="dialog" aria-label={`Actions for ${props.scriptName}`}>
     422            1 :                 <ActionList.List aria-label={`Actions for ${props.scriptName}`}>
     423              :                     {/* The list takes elements only, the fragments wrap the optional items */}
     424            1 :                     <>{props.onRename != null && (
     425            1 :                         <ActionList.ListItem data-testid="dashboard-card-menu-item" data-action="rename" onClick={() => run(props.onRename!)}>
     426            1 :                             <ActionList.Leading><RenameIcon size={16} /></ActionList.Leading>
     427            1 :                             <ActionList.ItemText>Rename</ActionList.ItemText>
     428            1 :                         </ActionList.ListItem>
     429            1 :                     )}</>
     430            1 :                     <>{props.onDelete != null && (
     431            1 :                         <ActionList.ListItem
     432            1 :                             data-testid="dashboard-card-menu-item"
     433            1 :                             data-action="delete"
     434            1 :                             className={menuStyles.delete_action}
     435            1 :                             onClick={() => run(props.onDelete!)}
     436              :                         >
     437            1 :                             <ActionList.Leading><TrashIcon size={16} /></ActionList.Leading>
     438            1 :                             <ActionList.ItemText>Delete</ActionList.ItemText>
     439            1 :                         </ActionList.ListItem>
     440            1 :                     )}</>
     441            1 :                 </ActionList.List>
     442            1 :             </div>
     443            1 :         </AnchoredOverlay>
     444              :     );
     445            1 : }
        

Generated by: LCOV version 2.0-1