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