Line data Source code
1 : import * as React from 'react';
2 : import { flushSync } from 'react-dom';
3 : import * as styles from './session_feed.module.css';
4 : import * as dashql from '@ankoh/dashql-core';
5 : import { useSessionExecution } from '../scripts/session_execution_provider.js';
6 :
7 : import type { EditorView } from '@codemirror/view';
8 : import type { Icon } from '../../ui/foundations/symbol_icon.js';
9 : import { PlusIcon } from '../../ui/foundations/symbol_icon.js';
10 : import type { RowComponentProps } from 'react-window';
11 : import { useSortable } from '@dnd-kit/sortable';
12 : import { CSS } from '@dnd-kit/utilities';
13 :
14 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
15 : import { ButtonGroup } from '../../ui/foundations/button_group.js';
16 : import { IndicatorStatus } from '../../ui/foundations/status_indicator.js';
17 : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
18 : import { ConnectionHealth, type ConnectionState } from '../../connections/connection_state.js';
19 : import { ConnectorType } from '../../connections/connector_info.js';
20 : import { QueryExecutionStatus } from '../../query/query_execution_state.js';
21 : import { useCancelQuery, useQueryState } from '../../connections/query_executor.js';
22 : import type { StorageReader } from '../../storage/storage_provider.js';
23 : import { getScriptRefs, type SessionScripts, type ScriptData } from '../scripts/session_scripts.js';
24 : import { scriptDisplayName } from '../scripts/script_types.js';
25 : import { deriveEntryStatus, EntryStatusKind } from '../entry_status_model.js';
26 : import { EntryStatusBar } from '../entry_status_bar.js';
27 : import { CachedResultBean, QueryResultRerunButton } from '../persistence/query_result_cache_controls.js';
28 : import { ScriptDiagnosticsButton } from '../scripts/script_diagnostics.js';
29 : import { ScriptEditor } from '../scripts/script_editor.js';
30 : import { ScriptStatisticsBar } from '../scripts/script_statistics_bar.js';
31 : import { formatScriptEditor, formattingDialectForConnector, isScriptFormattable } from '../scripts/script_format.js';
32 : import { FeedEntryFooter, FeedEntryTabStrip, useFeedEntryTabs } from './feed_entry_footer.js';
33 : import type { ScriptStorage } from '../scripts/script_action_menu.js';
34 : import { displayPath } from '../../storage/session_locator.js';
35 : import { STORAGE_SCRIPTS_FOLDER } from '../../storage/storage_backend.js';
36 : import type { ScriptConnectionView } from '../scripts/script_connection_picker.js';
37 : import { ChartBuilderOverlay } from '../chart_builder/chart_builder_overlay.js';
38 : import { ResultTableOverlay } from '../../views/query_result/result_table_overlay.js';
39 : import { FeedEntryHeader } from './feed_entry_header.js';
40 : import { FEED_TOP_PADDING } from './session_feed_layout.js';
41 : import type { ScriptReferenceReport } from '../scripts/script_reference_check.js';
42 :
43 1 : const DragHandleIcon: Icon = SymbolIcon('drag_handle_16');
44 :
45 : export interface ScriptCardProps {
46 : sessionId: string;
47 : /// The connection fields of the card, the connection the script runs on.
48 : /// The connection state itself changes with every query event of the session.
49 : connectionId: string | null;
50 : connectionHealth: ConnectionHealth | null;
51 : connectionDetails: ConnectionState['details'] | null;
52 : connectorType: ConnectorType;
53 : /// The connection the script runs on, null while it is not registered
54 : connection: ScriptConnectionView | null;
55 : /// The cache key of the result of a script, the key that a run of the script reads and writes
56 : computeCacheKey: (scriptKey: number) => Promise<string | null>;
57 : /// Is a cache key cached in the query result cache of the app?
58 : /// The time the result of a cache key was written, null if the key is not cached
59 : lookupCachedResult: (cacheKey: string) => Promise<number | null>;
60 : storageReader: StorageReader;
61 : isFocused: boolean;
62 : scriptData: ScriptData | undefined;
63 : scriptFileName: string;
64 : scriptDebugMode: boolean;
65 : formattingDebugMode: boolean;
66 : keywordCase: dashql.buffers.formatting.KeywordCase;
67 : canExecute: boolean;
68 : active: boolean;
69 : canMoveUp: boolean;
70 : canMoveDown: boolean;
71 : /// The position of the script in the feed
72 : index: number;
73 : /// Add a script at a position of the feed
74 : onCreate: (index: number) => void;
75 : onFocus: (fileName: string) => void;
76 : onDelete: (fileName: string) => void;
77 : onRename: (oldFileName: string, newFileName: string) => void;
78 : /// Run the script on another connection
79 : onPickConnection: (fileName: string, connectionId: string) => void;
80 : onMoveUp: (fileName: string) => void;
81 : onMoveDown: (fileName: string) => void;
82 : onExecute: (fileName: string) => void;
83 : onRerun: (fileName: string) => void;
84 : onFormat: (scriptKey: number, text: string) => void;
85 : /// Replace the text of the script with the text of a snippet
86 : onApplySnippet: (scriptKey: number, text: string) => void;
87 : /// Write a Vega-Lite spec of the chart builder into the `@vis vegalite` tag of the script
88 : onSetVisSpec: (scriptKey: number, spec: string) => void;
89 : /// Can a question of the composer target the script?
90 : canTarget: boolean;
91 : /// The checked script references of the script
92 : scriptReferences?: ScriptReferenceReport | null;
93 : /// Reference the script in the question of the composer
94 : onTarget: (fileName: string) => void;
95 : onAcceptDiff: (scriptKey: number) => void;
96 : onRejectDiff: (scriptKey: number) => void;
97 : resultExpanded: boolean;
98 : onToggleResultExpanded: (scriptKey: number) => void;
99 : onAutoCollapseResult: (scriptKey: number, queryId: number) => void;
100 : onResetAutoCollapsedResult: (scriptKey: number, queryId: number | null) => void;
101 : onEditorView: (scriptKey: number, view: EditorView) => void;
102 : dependencies?: ScriptCardDependencies;
103 : }
104 :
105 : export interface ScriptCardDependencies {
106 : useQueryState: typeof useQueryState;
107 : useCancelQuery: typeof useCancelQuery;
108 : ScriptEditor: typeof ScriptEditor;
109 : ScriptDiagnosticsButton: typeof ScriptDiagnosticsButton;
110 : ScriptStatisticsBar: typeof ScriptStatisticsBar;
111 : formatScriptEditor: typeof formatScriptEditor;
112 : isScriptFormattable: typeof isScriptFormattable;
113 : CachedResultBean: typeof CachedResultBean;
114 : QueryResultRerunButton: typeof QueryResultRerunButton;
115 : EntryStatusBar: typeof EntryStatusBar;
116 : FeedEntryFooter: typeof FeedEntryFooter;
117 : FeedEntryTabStrip: typeof FeedEntryTabStrip;
118 : ChartBuilderOverlay: typeof ChartBuilderOverlay;
119 : ResultTableOverlay: typeof ResultTableOverlay;
120 : }
121 :
122 1 : const DEFAULT_SCRIPT_CARD_DEPENDENCIES: ScriptCardDependencies = {
123 1 : useQueryState,
124 1 : useCancelQuery,
125 1 : ScriptEditor,
126 1 : ScriptDiagnosticsButton,
127 1 : ScriptStatisticsBar,
128 1 : formatScriptEditor,
129 1 : isScriptFormattable,
130 1 : CachedResultBean,
131 1 : QueryResultRerunButton,
132 1 : EntryStatusBar,
133 1 : FeedEntryFooter,
134 1 : FeedEntryTabStrip,
135 1 : ChartBuilderOverlay,
136 1 : ResultTableOverlay,
137 1 : };
138 :
139 : // Memoized since every row of the feed re-renders whenever any script changes
140 1 : export const ScriptCard: React.FC<ScriptCardProps> = React.memo((props: ScriptCardProps) => {
141 1 : const sessionExecution = useSessionExecution(props.sessionId);
142 1 : const dependencies = props.dependencies ?? DEFAULT_SCRIPT_CARD_DEPENDENCIES;
143 1 : const CheckIcon: Icon = SymbolIcon('check_16');
144 1 : const CrossIcon: Icon = SymbolIcon('x_16');
145 :
146 1 : const queryState = dependencies.useQueryState(props.sessionId, props.scriptData?.latestQueryId ?? null);
147 1 : const cancelQuery = dependencies.useCancelQuery();
148 :
149 : // A staged rewrite waiting to be accepted/rejected. The editable editor renders the
150 : // in-place diff and these actions provide the same explicit accept/reject alternatives.
151 1 : const hasPendingDiff = props.scriptData?.pendingDiff != null;
152 :
153 : // Is the script cached?
154 : // Planning the script is a full pass over the scripts it needs, only re-check when the text, the catalog,
155 : // the queries or their cache entries changed (and not on every cursor move).
156 : // The time the cached result of a run of the script was written, null if it is not cached
157 1 : const [cachedAtMs, setCachedAtMs] = React.useState<number | null>(null);
158 1 : React.useEffect(() => {
159 1 : const cancel = new AbortController();
160 1 : const connectionDetails = props.connectionDetails;
161 1 : const run = async () => {
162 : // Not script data? Clear cache indicator.
163 1 : if (props.scriptData == null || !connectionDetails) {
164 1 : setCachedAtMs(null);
165 1 : return;
166 1 : }
167 : // Outdated analysis? Keep the last known indicator until the script is re-analyzed.
168 : // Every keystroke outdates all scripts of the session, clearing it would make it flicker.
169 1 : if (props.scriptData.analysisOutdated) {
170 1 : return;
171 : }
172 1 : try {
173 : // The key of a run of the script, null if its result is not cacheable
174 1 : const cacheKey = await props.computeCacheKey(props.scriptData.scriptKey);
175 : // Look up the key in the cache of the app
176 1 : const cachedAt = cacheKey != null ? await props.lookupCachedResult(cacheKey) : null;
177 1 : if (!cancel.signal.aborted) {
178 1 : setCachedAtMs(cachedAt);
179 : }
180 1 : } catch { }
181 1 : };
182 1 : run();
183 1 : return () => cancel.abort();
184 1 : }, [
185 1 : props.scriptData?.scriptSession,
186 1 : props.scriptData?.analysisOutdated,
187 1 : props.scriptData?.editorUpdate?.textVersion,
188 1 : props.scriptData?.editorUpdate?.catalogRevision,
189 1 : props.scriptData?.latestQueryId,
190 1 : props.scriptData?.latestResultQueryId,
191 1 : queryState?.cacheKey,
192 1 : queryState?.cacheDeleted,
193 1 : props.connectionDetails,
194 1 : props.computeCacheKey,
195 1 : props.lookupCachedResult,
196 1 : props.sessionId,
197 1 : ]);
198 :
199 : // The result header persists for active and terminal execution states. It can collapse the
200 : // result body without affecting its cancel, cache, and rerun actions.
201 1 : const entryStatus = deriveEntryStatus(queryState);
202 1 : const scriptKey = props.scriptData?.scriptKey ?? null;
203 : // The result tabs live in the status bar, their panel below it
204 1 : const resultTabs = useFeedEntryTabs(queryState, props.scriptData?.annotations.visualizeQuery ?? null);
205 : // Picking a tab of a collapsed result shows it
206 1 : const expandResult = React.useCallback(() => {
207 0 : if (scriptKey != null && !props.resultExpanded) props.onToggleResultExpanded(scriptKey);
208 1 : }, [scriptKey, props.resultExpanded, props.onToggleResultExpanded]);
209 1 : React.useEffect(() => {
210 1 : if (scriptKey == null) return;
211 1 : if (queryState?.status !== QueryExecutionStatus.SUCCEEDED || queryState.resultTable !== null) {
212 1 : props.onResetAutoCollapsedResult(scriptKey, queryState?.queryId ?? props.scriptData?.latestQueryId ?? null);
213 1 : return;
214 1 : }
215 1 : props.onAutoCollapseResult(scriptKey, queryState.queryId);
216 1 : }, [props.onAutoCollapseResult, props.onResetAutoCollapsedResult, props.scriptData?.latestQueryId, queryState, scriptKey]);
217 1 : const cancelEntryOperation = React.useCallback(() => {
218 0 : if (entryStatus?.kind === EntryStatusKind.Query && props.scriptData?.latestQueryId != null) {
219 0 : if (sessionExecution.busy) sessionExecution.stop();
220 0 : else if (props.connectionId != null) cancelQuery(props.connectionId, props.scriptData.latestQueryId);
221 0 : }
222 1 : }, [cancelQuery, entryStatus?.kind, props.connectionId, props.scriptData?.latestQueryId, sessionExecution.busy, sessionExecution.stop]);
223 :
224 1 : const acceptDiff = React.useCallback(() => {
225 1 : if (scriptKey != null) props.onAcceptDiff(scriptKey);
226 1 : }, [scriptKey, props.onAcceptDiff]);
227 1 : const rejectDiff = React.useCallback(() => {
228 1 : if (scriptKey != null) props.onRejectDiff(scriptKey);
229 1 : }, [scriptKey, props.onRejectDiff]);
230 :
231 1 : const [editorView, setEditorView] = React.useState<EditorView | null>(null);
232 1 : const formattingDialect = formattingDialectForConnector(props.connectorType);
233 : // Formattability only depends on the text, cursor moves bump the state revision but not the text version
234 1 : const isFormattable = React.useMemo(
235 1 : () => dependencies.isScriptFormattable(props.scriptData ?? null, formattingDialect),
236 1 : [props.scriptData?.scriptSession, props.scriptData?.editorUpdate?.textVersion, formattingDialect],
237 1 : );
238 1 : const handleEditorView = React.useCallback((view: EditorView) => {
239 1 : setEditorView(view);
240 1 : if (props.scriptData != null) props.onEditorView(props.scriptData.scriptKey, view);
241 1 : }, [props.onEditorView, props.scriptData?.scriptKey]);
242 1 : const handleFormat = React.useCallback((mode: dashql.buffers.formatting.FormattingMode) => {
243 1 : dependencies.formatScriptEditor(
244 1 : editorView,
245 1 : props.scriptData ?? null,
246 1 : mode,
247 1 : (text) => {
248 1 : if (props.scriptData != null) props.onFormat(props.scriptData.scriptKey, text);
249 1 : },
250 1 : props.formattingDebugMode,
251 1 : props.keywordCase,
252 1 : formattingDialect,
253 1 : );
254 1 : }, [editorView, props.formattingDebugMode, props.keywordCase, formattingDialect, props.onFormat, props.scriptData]);
255 :
256 : // Labels use the display name (the file name without ".sql"); the raw scriptFileName remains the
257 : // identity passed to handlers and to RENAME_SCRIPT.
258 1 : const applySnippet = React.useCallback((text: string) => {
259 0 : if (scriptKey != null) props.onApplySnippet(scriptKey, text);
260 1 : }, [scriptKey, props.onApplySnippet]);
261 1 : const setVisSpec = React.useCallback((spec: string) => {
262 0 : if (scriptKey != null) props.onSetVisSpec(scriptKey, spec);
263 1 : }, [scriptKey, props.onSetVisSpec]);
264 :
265 1 : const displayName = scriptDisplayName(props.scriptFileName);
266 1 : const storage = React.useMemo<ScriptStorage>(() => ({
267 1 : fileName: props.scriptFileName,
268 1 : resolvePath: () => `${displayPath(props.sessionId, props.storageReader.getSessionLocation(props.sessionId))}/${STORAGE_SCRIPTS_FOLDER}/${props.scriptFileName}`,
269 1 : onRename: name => props.onRename(props.scriptFileName, name),
270 1 : }), [props.scriptFileName, props.sessionId, props.storageReader, props.onRename]);
271 :
272 : // The full result table opens in a card over the feed
273 1 : const [tableOverlayOpen, setTableOverlayOpen] = React.useState(false);
274 1 : const showTable = React.useCallback(() => setTableOverlayOpen(true), []);
275 1 : const closeTableOverlay = React.useCallback(() => setTableOverlayOpen(false), []);
276 :
277 1 : const resultContentId = props.scriptData != null ? `feed-result-${props.scriptData.scriptKey}` : undefined;
278 1 : const hasFooterContent = entryStatus.kind !== EntryStatusKind.Idle;
279 1 : return (
280 1 : <article
281 1 : className={styles.feed_entry_pair}
282 1 : data-electron-drag-region="false"
283 1 : onPointerEnter={() => props.onFocus(props.scriptFileName)}
284 1 : aria-label={`${displayName} script cell`}
285 1 : data-testid="script-cell"
286 : >
287 1 : <div className={styles.feed_entry_cell} data-result-card={hasFooterContent ? '' : undefined}>
288 1 : <FeedEntryHeader
289 1 : sessionId={props.sessionId}
290 1 : scriptData={props.scriptData}
291 1 : scriptFileName={props.scriptFileName}
292 1 : connection={props.connection}
293 1 : connectorType={props.connectorType}
294 1 : queryState={queryState}
295 1 : focused={props.isFocused}
296 1 : canExecute={props.canExecute}
297 1 : scriptDebugMode={props.scriptDebugMode}
298 1 : isFormattable={isFormattable}
299 1 : canFormat={editorView != null}
300 1 : scriptReferences={props.scriptReferences ?? null}
301 1 : canTarget={props.canTarget}
302 1 : onTarget={() => props.onTarget(props.scriptFileName)}
303 1 : onExecute={() => props.onExecute(props.scriptFileName)}
304 1 : onPickConnection={connectionId => props.onPickConnection(props.scriptFileName, connectionId)}
305 1 : onRename={name => props.onRename(props.scriptFileName, name)}
306 1 : onDelete={() => props.onDelete(props.scriptFileName)}
307 1 : onFormat={handleFormat}
308 1 : onApplySnippet={applySnippet}
309 1 : onSetVisSpec={setVisSpec}
310 1 : storage={storage}
311 1 : onAddAbove={() => props.onCreate(props.index)}
312 1 : onAddBelow={() => props.onCreate(props.index + 1)}
313 1 : onMoveUp={() => props.onMoveUp(props.scriptFileName)}
314 1 : onMoveDown={() => props.onMoveDown(props.scriptFileName)}
315 1 : canMoveUp={props.canMoveUp}
316 1 : canMoveDown={props.canMoveDown}
317 1 : dependencies={dependencies}
318 1 : />
319 1 : <div className={styles.feed_body}>
320 1 : <div className={styles.feed_editor_container}>
321 1 : {props.scriptData == null || !props.active ? null : (
322 1 : <dependencies.ScriptEditor
323 1 : sessionId={props.sessionId}
324 1 : scriptKey={props.scriptData.scriptKey}
325 1 : className={styles.feed_script_editor}
326 1 : autoHeight
327 1 : lineWrapping
328 1 : onFocus={() => props.onFocus(props.scriptFileName)}
329 1 : setView={handleEditorView}
330 1 : />
331 : )}
332 1 : {hasPendingDiff && (
333 1 : <div className={styles.feed_body_overlays}>
334 1 : <div data-diff-actions>
335 1 : <ButtonGroup>
336 1 : <IconButton
337 1 : variant={ButtonVariant.Default}
338 1 : size={ButtonSize.Small}
339 1 : onClick={acceptDiff}
340 1 : aria-label="Accept rewrite"
341 1 : data-testid="diff-accept"
342 : >
343 1 : <CheckIcon size={14} />
344 1 : </IconButton>
345 1 : <IconButton
346 1 : variant={ButtonVariant.Default}
347 1 : size={ButtonSize.Small}
348 1 : onClick={rejectDiff}
349 1 : aria-label="Reject rewrite"
350 1 : data-testid="diff-reject"
351 : >
352 1 : <CrossIcon size={14} />
353 1 : </IconButton>
354 1 : </ButtonGroup>
355 1 : </div>
356 1 : </div>
357 : )}
358 1 : </div>
359 1 : </div>
360 1 : {hasFooterContent && (
361 1 : <div className={styles.feed_entry_footer} data-feed-result="">
362 1 : <dependencies.EntryStatusBar
363 1 : status={entryStatus}
364 1 : onToggleExpanded={() => {
365 0 : if (scriptKey != null) props.onToggleResultExpanded(scriptKey);
366 0 : }}
367 1 : expanded={props.resultExpanded}
368 1 : controls={resultContentId}
369 1 : onCancel={entryStatus.indicator === IndicatorStatus.Running ? cancelEntryOperation : undefined}
370 1 : cancelLabel="Cancel query"
371 1 : compact
372 1 : tabs={
373 1 : <dependencies.FeedEntryTabStrip
374 1 : tabs={resultTabs}
375 1 : expanded={props.resultExpanded}
376 1 : onSelect={expandResult}
377 1 : />
378 : }
379 1 : actions={
380 1 : <>
381 1 : {cachedAtMs != null && <dependencies.CachedResultBean cachedAtMs={cachedAtMs} />}
382 1 : <dependencies.QueryResultRerunButton
383 1 : query={queryState}
384 1 : onRerun={props.connectionHealth === ConnectionHealth.ONLINE && !sessionExecution.runActive
385 1 : ? () => props.onRerun(props.scriptFileName) : undefined}
386 1 : />
387 1 : </>
388 : }
389 1 : />
390 1 : {queryState != null ? (
391 1 : <div id={resultContentId} hidden={!props.resultExpanded}>
392 1 : <dependencies.FeedEntryFooter
393 1 : sessionId={props.sessionId}
394 1 : queryState={queryState}
395 1 : visualizeQuery={props.scriptData?.annotations.visualizeQuery ?? null}
396 1 : selectedTab={resultTabs.selectedTab}
397 1 : onShowTable={showTable}
398 1 : />
399 1 : </div>
400 1 : ) : null}
401 1 : {tableOverlayOpen && queryState != null && (
402 1 : <dependencies.ResultTableOverlay
403 1 : scriptName={displayName}
404 1 : query={queryState}
405 1 : onClose={closeTableOverlay}
406 1 : />
407 : )}
408 1 : </div>
409 : )}
410 1 : </div>
411 1 : </article>
412 : );
413 1 : });
414 :
415 : export interface ScriptFeedRowProps {
416 : sessionId: string;
417 : /// The connections that the scripts run on
418 : connections: ReadonlyMap<string, ScriptConnectionView>;
419 : /// The cache key of the result of a script, the key that a run of the script reads and writes
420 : computeCacheKey: (scriptKey: number) => Promise<string | null>;
421 : /// Is a cache key cached in the query result cache of the app?
422 : /// The time the result of a cache key was written, null if the key is not cached
423 : lookupCachedResult: (cacheKey: string) => Promise<number | null>;
424 : storageReader: StorageReader;
425 : entries: ReturnType<typeof getScriptRefs>;
426 : scripts: SessionScripts['scripts'];
427 : scriptDebugMode: boolean;
428 : formattingDebugMode: boolean;
429 : keywordCase: dashql.buffers.formatting.KeywordCase;
430 : focusedFileName: string;
431 : active: boolean;
432 : onFocus: (fileName: string) => void;
433 : onDelete: (fileName: string) => void;
434 : onRename: (oldFileName: string, newFileName: string) => void;
435 : onPickConnection: (fileName: string, connectionId: string) => void;
436 : onMoveUp: (fileName: string) => void;
437 : onMoveDown: (fileName: string) => void;
438 : onExecute: (fileName: string) => void;
439 : onRerun: (fileName: string) => void;
440 : onFormat: (scriptKey: number, text: string) => void;
441 : /// Replace the text of a script with the text of a snippet
442 : onApplySnippet: (scriptKey: number, text: string) => void;
443 : /// Write a Vega-Lite spec of the chart builder into the `@vis vegalite` tag of a script
444 : onSetVisSpec: (scriptKey: number, spec: string) => void;
445 : /// Can a question of the composer target the scripts?
446 : /// The checked script references of the scripts, by script key
447 : scriptReferences?: ReadonlyMap<number, ScriptReferenceReport>;
448 : canTarget: boolean;
449 : /// Reference a script in the question of the composer
450 : onTarget: (fileName: string) => void;
451 : onAcceptDiff: (scriptKey: number) => void;
452 : onRejectDiff: (scriptKey: number) => void;
453 : collapsedResults: ReadonlyMap<number, number | null>;
454 : onToggleResultExpanded: (scriptKey: number) => void;
455 : onAutoCollapseResult: (scriptKey: number, queryId: number) => void;
456 : onResetAutoCollapsedResult: (scriptKey: number, queryId: number | null) => void;
457 : onCreate: (index: number) => void;
458 : onEditorView: (scriptKey: number, view: EditorView) => void;
459 : onRowHeightChange: (index: number, height: number) => void;
460 : dependencies?: ScriptFeedRowDependencies;
461 : }
462 :
463 : export interface ScriptFeedRowDependencies {
464 : useSortable: typeof useSortable;
465 : translateTransform: typeof CSS.Translate.toString;
466 : scriptCard: ScriptCardDependencies;
467 : }
468 :
469 1 : const DEFAULT_SCRIPT_FEED_ROW_DEPENDENCIES: ScriptFeedRowDependencies = {
470 1 : useSortable,
471 1 : translateTransform: CSS.Translate.toString,
472 1 : scriptCard: DEFAULT_SCRIPT_CARD_DEPENDENCIES,
473 1 : };
474 :
475 1 : export function ScriptFeedRow(props: RowComponentProps<ScriptFeedRowProps>) {
476 1 : const dependencies = props.dependencies ?? DEFAULT_SCRIPT_FEED_ROW_DEPENDENCIES;
477 1 : const entryIndex = props.index;
478 1 : const entry = props.entries[entryIndex];
479 1 : const scriptData = entry != null ? props.scripts[entry.scriptId] : undefined;
480 1 : const scriptFileName = entry?.fileName ?? 'script.sql';
481 1 : const displayName = scriptDisplayName(scriptFileName);
482 : // Every script runs on its own connection
483 1 : const connection = scriptData != null ? props.connections.get(scriptData.connection.connectionId) ?? null : null;
484 :
485 : // Entries are in feed order, so the index drives the movement buttons
486 1 : const canMoveUp = entryIndex > 0;
487 1 : const canMoveDown = entryIndex < props.entries.length - 1;
488 1 : const {
489 1 : attributes,
490 1 : listeners,
491 1 : setNodeRef,
492 1 : transform,
493 1 : transition,
494 1 : isDragging,
495 1 : } = dependencies.useSortable({
496 1 : id: entry?.scriptId ?? `missing-${entryIndex}`,
497 : // The optimistic order is already the final dropped layout. Suppress dnd-kit's derived
498 : // post-drop FLIP transform, which otherwise briefly animates the card back toward its old row.
499 1 : animateLayoutChanges: () => false,
500 1 : });
501 1 : const rowRef = React.useRef<HTMLDivElement | null>(null);
502 1 : const setRowRef = React.useCallback((node: HTMLDivElement | null) => {
503 1 : rowRef.current = node;
504 1 : setNodeRef(node);
505 1 : }, [setNodeRef]);
506 : // The height cache stores the height of the script at the index of a row.
507 : // A row measures again when its index or its script changes: a new size observer reports the size once, even if
508 : // it did not change. That report comes before the paint, when the editor of a mounted row exists. A measure in this
509 : // effect would see the card without its editor and move all rows below by the height of the editor.
510 : // The rows below move in the same frame, a height applied after the paint would show them at the old height.
511 1 : const rowScriptId = entry?.scriptId;
512 1 : React.useLayoutEffect(() => {
513 1 : const row = rowRef.current;
514 1 : if (row == null) return;
515 1 : const observer = new ResizeObserver(() => {
516 1 : const height = Math.ceil(row.getBoundingClientRect().height);
517 1 : if (height > 0) flushSync(() => props.onRowHeightChange(props.index, height));
518 1 : });
519 1 : observer.observe(row);
520 1 : return () => observer.disconnect();
521 1 : }, [props.index, rowScriptId, props.onRowHeightChange]);
522 :
523 : // The row holds the card and the space below it, the first row also the space above the feed.
524 : // The add buttons sit in these spaces, a button between two scripts shows while either one is hovered.
525 1 : return (
526 1 : <div
527 1 : className={styles.feed_row}
528 1 : style={{ ...props.style, paddingTop: props.index === 0 ? FEED_TOP_PADDING : undefined }}
529 1 : data-testid="script-feed-row"
530 : >
531 1 : <div
532 1 : ref={setRowRef}
533 1 : className={styles.feed_list_item}
534 1 : data-testid="script-feed-item"
535 1 : data-script-key={entry?.scriptId}
536 1 : style={{
537 : // dnd-kit may scale the active item toward a differently sized target row.
538 : // Feed cards have variable heights, so scaling visibly squashes or stretches them.
539 1 : transform: dependencies.translateTransform(transform),
540 1 : transition,
541 1 : zIndex: isDragging ? 1 : undefined,
542 1 : }}
543 : >
544 1 : <button
545 1 : type="button"
546 1 : className={styles.feed_entry_insert}
547 1 : data-edge="above"
548 1 : aria-label={`Add script above ${displayName}`}
549 1 : data-testid="script-insert-above"
550 1 : onClick={() => props.onCreate(entryIndex)}
551 : >
552 1 : <PlusIcon size={14} />
553 1 : </button>
554 1 : <button
555 1 : type="button"
556 1 : className={styles.feed_entry_drag_handle}
557 1 : aria-label={`Drag ${displayName} script to reorder`}
558 1 : data-testid="script-drag-handle"
559 1 : {...attributes}
560 1 : {...listeners}
561 : >
562 1 : <DragHandleIcon size={16} />
563 1 : </button>
564 1 : <ScriptCard
565 1 : key={entry?.scriptId}
566 1 : sessionId={props.sessionId}
567 1 : connectionId={scriptData?.connection.connectionId ?? null}
568 1 : connectionHealth={connection?.connectionHealth ?? null}
569 1 : connectionDetails={connection?.details ?? null}
570 1 : connectorType={scriptData?.connection.connectorInfo.connectorType ?? ConnectorType.HYPER}
571 1 : connection={connection}
572 1 : computeCacheKey={props.computeCacheKey}
573 1 : lookupCachedResult={props.lookupCachedResult}
574 1 : storageReader={props.storageReader}
575 1 : isFocused={scriptFileName === props.focusedFileName}
576 1 : scriptData={scriptData}
577 1 : scriptFileName={scriptFileName}
578 1 : scriptDebugMode={props.scriptDebugMode}
579 1 : formattingDebugMode={props.formattingDebugMode}
580 1 : keywordCase={props.keywordCase}
581 1 : canExecute={scriptData != null && connection?.connectionHealth === ConnectionHealth.ONLINE}
582 1 : active={props.active}
583 1 : canMoveUp={canMoveUp}
584 1 : canMoveDown={canMoveDown}
585 1 : index={entryIndex}
586 1 : onCreate={props.onCreate}
587 1 : onFocus={props.onFocus}
588 1 : onDelete={props.onDelete}
589 1 : onRename={props.onRename}
590 1 : onPickConnection={props.onPickConnection}
591 1 : onMoveUp={props.onMoveUp}
592 1 : onMoveDown={props.onMoveDown}
593 1 : onExecute={props.onExecute}
594 1 : onRerun={props.onRerun}
595 1 : onFormat={props.onFormat}
596 1 : onApplySnippet={props.onApplySnippet}
597 1 : onSetVisSpec={props.onSetVisSpec}
598 1 : canTarget={props.canTarget}
599 1 : scriptReferences={scriptData != null ? props.scriptReferences?.get(scriptData.scriptKey) ?? null : null}
600 1 : onTarget={props.onTarget}
601 1 : onAcceptDiff={props.onAcceptDiff}
602 1 : onRejectDiff={props.onRejectDiff}
603 1 : resultExpanded={scriptData == null || !props.collapsedResults.has(scriptData.scriptKey)}
604 1 : onToggleResultExpanded={props.onToggleResultExpanded}
605 1 : onAutoCollapseResult={props.onAutoCollapseResult}
606 1 : onResetAutoCollapsedResult={props.onResetAutoCollapsedResult}
607 1 : onEditorView={props.onEditorView}
608 1 : dependencies={dependencies.scriptCard}
609 1 : />
610 1 : <button
611 1 : type="button"
612 1 : className={styles.feed_entry_insert}
613 1 : data-edge="below"
614 1 : aria-label={`Add script below ${displayName}`}
615 1 : data-testid="script-insert-below"
616 1 : onClick={() => props.onCreate(entryIndex + 1)}
617 : >
618 1 : <PlusIcon size={14} />
619 1 : </button>
620 1 : </div>
621 1 : </div>
622 : );
623 1 : }
|