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