Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './session_feed.module.css';
3 : import type * as dashql from '@ankoh/dashql-core';
4 :
5 : import type { Icon } from '../../ui/foundations/symbol_icon.js';
6 : import { GraphIcon, PaperAirplaneIcon, PencilIcon, SquareFillIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
7 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
8 : import { useRowContextMenu } from '../../ui/foundations/row_context_menu.js';
9 : import { ConnectionHealth } from '../../connections/connection_state.js';
10 : import type { ConnectorType } from '../../connections/connector_info.js';
11 : import { useCancelQuery } from '../../connections/query_executor.js';
12 : import { queryIsDone, type QueryExecutionState } from '../../query/query_execution_state.js';
13 : import { ExecuteConnectionGuard, SessionConnectionAttentionContext } from '../session_connection_attention.js';
14 : import { useSessionExecution } from '../scripts/session_execution_provider.js';
15 : import type { ScriptData } from '../scripts/session_scripts.js';
16 : import { scriptDisplayName } from '../scripts/script_types.js';
17 : import { ScriptDiagnosticsButton } from '../scripts/script_diagnostics.js';
18 : import { ScriptStatisticsBar } from '../scripts/script_statistics_bar.js';
19 : import type { ScriptReferenceReport } from '../scripts/script_reference_check.js';
20 : import { ScriptActionMenu, type ScriptStorage } from '../scripts/script_action_menu.js';
21 : import { ScriptConnectionPicker, type ScriptConnectionView } from '../scripts/script_connection_picker.js';
22 : import { SnippetsOverlay } from '../snippets/snippets_overlay.js';
23 : import { ChartBuilderOverlay } from '../chart_builder/chart_builder_overlay.js';
24 : import { ScriptMentionTag } from './script_mention_tag.js';
25 : import { classNames } from '../../utils/classnames.js';
26 :
27 1 : const MoveUpIcon: Icon = SymbolIcon('chevron_up_16');
28 1 : const MoveDownIcon: Icon = SymbolIcon('chevron_down_16');
29 1 : const TrashIcon: Icon = SymbolIcon('trash_16');
30 :
31 : export interface FeedEntryHeaderDependencies {
32 : useCancelQuery: typeof useCancelQuery;
33 : ScriptDiagnosticsButton: typeof ScriptDiagnosticsButton;
34 : ScriptStatisticsBar: typeof ScriptStatisticsBar;
35 : ChartBuilderOverlay: typeof ChartBuilderOverlay;
36 : }
37 :
38 1 : export const DEFAULT_FEED_ENTRY_HEADER_DEPENDENCIES: FeedEntryHeaderDependencies = {
39 1 : useCancelQuery,
40 1 : ScriptDiagnosticsButton,
41 1 : ScriptStatisticsBar,
42 1 : ChartBuilderOverlay,
43 1 : };
44 :
45 : export interface FeedEntryHeaderProps {
46 : sessionId: string;
47 : scriptData: ScriptData | undefined;
48 : scriptFileName: string;
49 : /// The connection the script runs on, null while it is not registered
50 : connection: ScriptConnectionView | null;
51 : connectorType: ConnectorType;
52 : /// The latest query of the script
53 : queryState: QueryExecutionState | null;
54 : /// A focused entry shows its run button in the base color
55 : focused: boolean;
56 : canExecute: boolean;
57 : scriptDebugMode: boolean;
58 : isFormattable: boolean;
59 : /// Formatting needs the editor of the script
60 : canFormat: boolean;
61 : /// The checked script references of the script
62 : scriptReferences: ScriptReferenceReport | null;
63 : /// Can a question of the composer target the script?
64 : canTarget: boolean;
65 : onTarget: () => void;
66 : onExecute: () => void;
67 : onPickConnection: (connectionId: string) => void;
68 : /// Rename the script, the name is the new display name
69 : onRename: (name: string) => void;
70 : onDelete: () => void;
71 : /// Open the script in an editor, an edit button left of the script menu offers it. Views without editors pass it,
72 : /// e.g. the dataflow.
73 : onEdit?: () => void;
74 : onFormat: (mode: dashql.buffers.formatting.FormattingMode) => void;
75 : /// Apply a snippet, the snippets button only shows with this handler
76 : onApplySnippet?: (text: string) => void;
77 : onSetVisSpec: (spec: string) => void;
78 : /// The storage path of the script, shown by the action menu
79 : storage: ScriptStorage;
80 : /// Add a script above or below, in the feed order
81 : onAddAbove?: () => void;
82 : onAddBelow?: () => void;
83 : /// Move the script in the feed order, the buttons only show with these handlers
84 : onMoveUp?: () => void;
85 : onMoveDown?: () => void;
86 : canMoveUp?: boolean;
87 : canMoveDown?: boolean;
88 : className?: string;
89 : dependencies?: FeedEntryHeaderDependencies;
90 : }
91 :
92 : /// The header of a script entry: run, connection, diagnostics, the name tag and the script actions.
93 : /// The feed cards, the boxes of the dataflow and the script editor dialog share it.
94 1 : export function FeedEntryHeader(props: FeedEntryHeaderProps): React.ReactElement {
95 1 : const dependencies = props.dependencies ?? DEFAULT_FEED_ENTRY_HEADER_DEPENDENCIES;
96 1 : const sessionExecution = useSessionExecution(props.sessionId);
97 1 : const cancelQuery = dependencies.useCancelQuery();
98 1 : const connectionAttention = React.useContext(SessionConnectionAttentionContext);
99 1 : const queryState = props.queryState;
100 1 : const queryActive = queryState != null && !queryIsDone(queryState.status);
101 1 : const hasPendingDiff = props.scriptData?.pendingDiff != null;
102 1 : const connectionId = props.connection?.connectionId ?? props.scriptData?.connection.connectionId ?? null;
103 1 : const connectionHealth = props.connection?.connectionHealth ?? null;
104 : // A script on a connection that is not connected keeps a greyed play button that pulses the connections in the bar
105 1 : const needsConnection = !queryActive && connectionId != null && connectionHealth !== ConnectionHealth.ONLINE;
106 1 : const displayName = scriptDisplayName(props.scriptFileName);
107 : // Is the name being edited in the tag of the entry? Rename in the script menu starts it
108 1 : const [renaming, setRenaming] = React.useState(false);
109 : // A right click on the action bar opens the script menu
110 1 : const contextMenu = useRowContextMenu();
111 1 : const [chartBuilderOpen, setChartBuilderOpen] = React.useState(false);
112 1 : const closeChartBuilder = React.useCallback(() => setChartBuilderOpen(false), []);
113 :
114 1 : return (
115 1 : <div className={classNames(styles.feed_entry_action_bar, props.className)} onContextMenu={contextMenu.onContextMenu}>
116 1 : <ExecuteConnectionGuard sessionId={props.sessionId} blocked={!queryActive && connectionHealth !== ConnectionHealth.ONLINE}>
117 1 : <IconButton
118 1 : className={needsConnection ? styles.feed_entry_execute_offline : props.focused ? styles.feed_entry_execute_focused : styles.feed_entry_execute_unfocused}
119 1 : variant={ButtonVariant.Invisible}
120 1 : size={ButtonSize.Small}
121 1 : aria-label={queryActive ? `Stop ${displayName} query` : `Execute ${displayName} query`}
122 1 : data-testid="script-execute"
123 1 : aria-current={props.focused ? 'true' : undefined}
124 1 : disabled={!queryActive && (sessionExecution.runActive || (needsConnection ? props.scriptData == null : !props.canExecute))}
125 1 : onClick={() => {
126 1 : if (queryActive && props.scriptData?.latestQueryId != null) {
127 0 : if (sessionExecution.busy) sessionExecution.stop();
128 0 : else if (connectionId != null) cancelQuery(connectionId, props.scriptData.latestQueryId);
129 1 : } else if (needsConnection) {
130 1 : connectionAttention?.request(props.sessionId);
131 : } else {
132 1 : props.onExecute();
133 : }
134 1 : }}
135 : >
136 1 : {queryActive ? <SquareFillIcon size={12} /> : <PaperAirplaneIcon size={14} />}
137 1 : </IconButton>
138 1 : </ExecuteConnectionGuard>
139 1 : <span className={styles.feed_entry_action_separator} aria-hidden="true" />
140 1 : <div className={styles.feed_entry_file_name}>
141 1 : <ScriptConnectionPicker
142 1 : sessionId={props.sessionId}
143 1 : scriptName={displayName}
144 1 : connection={props.connection}
145 1 : disabled={props.scriptData == null || queryActive || sessionExecution.busy}
146 1 : onPick={props.onPickConnection}
147 1 : entryHeader
148 1 : />
149 1 : </div>
150 1 : {props.scriptDebugMode && props.scriptData != null && (
151 1 : <div className={styles.feed_entry_stats_bar} data-entry-optional="1">
152 1 : <dependencies.ScriptStatisticsBar stats={props.scriptData.statistics} />
153 1 : </div>
154 : )}
155 1 : {props.scriptData != null && (
156 1 : <dependencies.ScriptDiagnosticsButton
157 1 : scriptData={props.scriptData}
158 1 : isFormattable={props.isFormattable}
159 1 : connectorType={props.connectorType}
160 1 : iconSize={14}
161 1 : references={props.scriptReferences}
162 1 : />
163 : )}
164 1 : {props.scriptData?.annotations.manual && (
165 1 : <span
166 1 : className={styles.feed_entry_manual}
167 1 : title="Runs only on request (@manual): Execute All and input changes skip this script"
168 1 : data-testid="script-manual"
169 1 : >
170 : manual
171 1 : </span>
172 : )}
173 1 : <ScriptMentionTag
174 1 : scriptName={displayName}
175 1 : canTarget={props.canTarget}
176 1 : onTarget={props.onTarget}
177 1 : renaming={renaming}
178 1 : onRenamingChange={setRenaming}
179 1 : onRename={props.onRename}
180 1 : />
181 1 : <IconButton
182 1 : variant={chartBuilderOpen ? ButtonVariant.Default : ButtonVariant.Invisible}
183 1 : size={ButtonSize.Small}
184 1 : onClick={(event) => { event.stopPropagation(); setChartBuilderOpen(true); }}
185 1 : aria-label={`Build a chart for ${displayName}`}
186 1 : aria-haspopup="dialog"
187 1 : aria-expanded={chartBuilderOpen}
188 1 : data-testid="script-chart"
189 1 : data-entry-optional="2"
190 1 : disabled={props.scriptData == null || hasPendingDiff}
191 : >
192 1 : <GraphIcon size={14} />
193 1 : </IconButton>
194 1 : {chartBuilderOpen && props.scriptData != null && (
195 1 : <dependencies.ChartBuilderOverlay
196 1 : scriptName={displayName}
197 1 : visualizeQuery={props.scriptData.annotations.visualizeQuery}
198 1 : queryState={queryState}
199 1 : onChange={props.onSetVisSpec}
200 1 : onClose={closeChartBuilder}
201 1 : />
202 : )}
203 1 : {props.onApplySnippet != null && (
204 1 : <span className={styles.feed_entry_optional} data-entry-optional="2">
205 1 : <SnippetsOverlay
206 1 : scriptName={displayName}
207 1 : connectorType={props.connectorType}
208 1 : disabled={props.scriptData == null || hasPendingDiff}
209 1 : onApply={props.onApplySnippet}
210 1 : iconSize={14}
211 1 : />
212 1 : </span>
213 : )}
214 1 : {props.onEdit != null && (
215 1 : <IconButton
216 1 : variant={ButtonVariant.Invisible}
217 1 : size={ButtonSize.Small}
218 1 : onClick={(event) => { event.stopPropagation(); props.onEdit?.(); }}
219 1 : aria-label={`Edit ${displayName} script`}
220 1 : data-testid="script-edit"
221 1 : disabled={props.scriptData == null}
222 : >
223 1 : <PencilIcon size={14} />
224 1 : </IconButton>
225 : )}
226 1 : <ScriptActionMenu
227 1 : scriptName={displayName}
228 1 : formatDisabled={!props.isFormattable || hasPendingDiff || !props.canFormat}
229 1 : onFormat={props.onFormat}
230 1 : onDelete={props.onDelete}
231 1 : onRename={() => setRenaming(true)}
232 1 : onAddAbove={props.onAddAbove}
233 1 : onAddBelow={props.onAddBelow}
234 1 : storage={props.storage}
235 1 : iconSize={14}
236 1 : contextMenu={contextMenu}
237 1 : />
238 1 : <span className={styles.feed_entry_action_separator} aria-hidden="true" data-entry-optional="1" />
239 1 : <IconButton
240 1 : variant={ButtonVariant.Invisible}
241 1 : size={ButtonSize.Small}
242 1 : onClick={(event) => { event.stopPropagation(); props.onDelete(); }}
243 1 : aria-label={`Delete ${displayName} script`}
244 1 : data-testid="script-delete"
245 1 : data-entry-optional="1"
246 : >
247 1 : <TrashIcon size={14} />
248 1 : </IconButton>
249 1 : {props.onMoveUp != null && (
250 1 : <IconButton
251 1 : variant={ButtonVariant.Invisible}
252 1 : size={ButtonSize.Small}
253 1 : onClick={(event) => { event.stopPropagation(); props.onMoveUp?.(); }}
254 1 : aria-label="Move script up"
255 1 : data-testid="script-move-up"
256 1 : disabled={!props.canMoveUp}
257 : >
258 1 : <MoveUpIcon size={14} />
259 1 : </IconButton>
260 : )}
261 1 : {props.onMoveDown != null && (
262 1 : <IconButton
263 1 : variant={ButtonVariant.Invisible}
264 1 : size={ButtonSize.Small}
265 1 : onClick={(event) => { event.stopPropagation(); props.onMoveDown?.(); }}
266 1 : aria-label="Move script down"
267 1 : data-testid="script-move-down"
268 1 : disabled={!props.canMoveDown}
269 : >
270 1 : <MoveDownIcon size={14} />
271 1 : </IconButton>
272 : )}
273 1 : </div>
274 : );
275 1 : }
|