Line data Source code
1 : import * as React from 'react';
2 : import type { EditorView } from '@codemirror/view';
3 : import * as styles from './script_editor_dialog.module.css';
4 :
5 : import { type ConnectionState } from '../../connections/connection_state.js';
6 : import { useCancelQuery, useQueryState } from '../../connections/query_executor.js';
7 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
8 : import { ButtonGroup } from '../../ui/foundations/button_group.js';
9 : import { Overlay, OverlaySize } from '../../ui/foundations/overlay.js';
10 : import { IndicatorStatus } from '../../ui/foundations/status_indicator.js';
11 : import { CheckIcon, ScreenFullIcon, ScreenNormalIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
12 : import { DashQLCompletionAbortEffect, DashQLCompletionStatus, DashQLProcessorPlugin } from '../editor/dashql_processor.js';
13 : import { acceptPendingDiff, rejectPendingDiff } from '../editor/dashql_diff_hint.js';
14 : import { EntryStatusBar } from '../entry_status_bar.js';
15 : import { deriveEntryStatus, EntryStatusKind } from '../entry_status_model.js';
16 : import { ScriptEntryHeader } from '../feed/script_entry_header.js';
17 : import { ACCEPT_PENDING_DIFF, DELETE_SCRIPT, REJECT_PENDING_DIFF, type ScriptData, type SessionScripts } from './session_scripts.js';
18 : import type { ModifySessionScripts } from './session_scripts_registry.js';
19 : import { ScriptEditor } from './script_editor.js';
20 : import { checkSessionScriptReferences } from './script_reference_reports.js';
21 : import type { RenameDraftReferences } from './script_rename.js';
22 : import { scriptDisplayName } from './script_types.js';
23 : import { useSessionExecution } from './session_execution_provider.js';
24 :
25 : export interface ScriptEditorDialogProps {
26 : sessionScripts: SessionScripts;
27 : modifySessionScripts: ModifySessionScripts;
28 : scriptKey: number;
29 : /// The connections that the scripts run on
30 : scriptConnections: ReadonlyMap<string, ConnectionState>;
31 : /// The function that renames the references in the draft of the composer
32 : draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
33 : /// Delete the script, the session deletes it by default. The dialog closes.
34 : onDelete?: (script: ScriptData) => void;
35 : /// Open another script, e.g. one that the script references
36 : onNavigateToScript?: (scriptKey: number) => void;
37 : onClose: () => void;
38 : }
39 :
40 : /// A script in a dialog over a view without editors, e.g. the dataflow: the header of the feed entry next to the
41 : /// expand and close buttons, the editor and the status of the last run. A staged rewrite shows as a diff in the
42 : /// editor, accepted or rejected over it.
43 1 : export function ScriptEditorDialog(props: ScriptEditorDialogProps): React.ReactElement | null {
44 1 : const { sessionScripts, modifySessionScripts, scriptKey, onClose } = props;
45 1 : const script = sessionScripts.scripts[scriptKey];
46 1 : const sessionId = sessionScripts.sessionId;
47 1 : const [editorView, setEditorView] = React.useState<EditorView | null>(null);
48 : // An expanded dialog fills the window
49 1 : const [expanded, setExpanded] = React.useState(false);
50 1 : const scriptReferences = React.useMemo(
51 1 : () => checkSessionScriptReferences(sessionScripts).get(scriptKey) ?? null,
52 1 : [sessionScripts, scriptKey],
53 1 : );
54 1 : const queryState = useQueryState(sessionId, script?.latestQueryId ?? null);
55 1 : const status = deriveEntryStatus(queryState);
56 1 : const cancelQuery = useCancelQuery();
57 1 : const sessionExecution = useSessionExecution(sessionId);
58 1 : const cancel = React.useCallback(() => {
59 0 : if (script?.latestQueryId == null) return;
60 0 : if (sessionExecution.busy) sessionExecution.stop();
61 0 : else cancelQuery(script.connection.connectionId, script.latestQueryId);
62 1 : }, [script, sessionExecution.busy, sessionExecution.stop, cancelQuery]);
63 :
64 : // Escape closes an open completion first, then the dialog
65 1 : const onEscape = React.useCallback(() => {
66 0 : const processor = editorView?.state.field(DashQLProcessorPlugin, false);
67 0 : if (editorView != null && processor?.scriptCompletion?.status === DashQLCompletionStatus.AVAILABLE) {
68 0 : editorView.dispatch({ effects: DashQLCompletionAbortEffect.of(null) });
69 0 : return;
70 0 : }
71 0 : onClose();
72 1 : }, [editorView, onClose]);
73 : // The menus of the header open outside of the dialog, a click outside keeps it open
74 1 : const ignoreOutsideClick = React.useCallback(() => {}, []);
75 1 : const remove = React.useCallback((target: ScriptData) => {
76 0 : if (props.onDelete != null) props.onDelete(target);
77 0 : else modifySessionScripts({ type: DELETE_SCRIPT, value: target.fileName });
78 0 : onClose();
79 1 : }, [props.onDelete, modifySessionScripts, onClose]);
80 : // The editor accepts or rejects a diff itself and clears the staged rewrite through the session
81 1 : const acceptDiff = React.useCallback(() => {
82 0 : if (editorView != null) acceptPendingDiff(editorView);
83 0 : else modifySessionScripts({ type: ACCEPT_PENDING_DIFF, value: scriptKey });
84 1 : }, [editorView, modifySessionScripts, scriptKey]);
85 1 : const rejectDiff = React.useCallback(() => {
86 0 : if (editorView != null) rejectPendingDiff(editorView);
87 0 : else modifySessionScripts({ type: REJECT_PENDING_DIFF, value: scriptKey });
88 1 : }, [editorView, modifySessionScripts, scriptKey]);
89 : // The editor takes the focus once it is there
90 1 : React.useEffect(() => {
91 1 : if (editorView == null) return;
92 1 : const frame = requestAnimationFrame(() => editorView.focus());
93 1 : return () => cancelAnimationFrame(frame);
94 1 : }, [editorView]);
95 :
96 1 : if (script == null) return null;
97 1 : const displayName = scriptDisplayName(script.fileName);
98 1 : return (
99 1 : <Overlay
100 1 : centered
101 1 : width={OverlaySize.XXL}
102 : // No max height, the overlay keeps the dialog inside a short window
103 1 : height={OverlaySize.XL}
104 1 : expanded={expanded}
105 1 : onEscape={onEscape}
106 1 : onClickOutside={ignoreOutsideClick}
107 : >
108 1 : <section
109 1 : className={styles.dialog}
110 1 : role="dialog"
111 1 : aria-modal="true"
112 1 : aria-label={`Edit ${displayName}`}
113 1 : data-expanded={expanded || undefined}
114 1 : data-testid="script-editor-dialog"
115 : >
116 1 : <div className={styles.titlebar}>
117 : {/* The entry header hides its optional controls when it gets narrow, the window controls stay */}
118 1 : <div className={styles.header_container}>
119 1 : <ScriptEntryHeader
120 1 : sessionScripts={sessionScripts}
121 1 : modifySessionScripts={modifySessionScripts}
122 1 : scriptKey={scriptKey}
123 1 : scriptConnections={props.scriptConnections}
124 1 : scriptReferences={scriptReferences}
125 1 : draftRenameRef={props.draftRenameRef}
126 1 : editorView={editorView}
127 1 : onDelete={remove}
128 1 : className={styles.header}
129 1 : />
130 1 : </div>
131 1 : <IconButton
132 1 : variant={ButtonVariant.Invisible}
133 1 : size={ButtonSize.Small}
134 1 : aria-label={expanded ? 'Restore size' : 'Expand'}
135 1 : aria-pressed={expanded}
136 1 : data-testid="script-editor-dialog-expand"
137 1 : onClick={() => setExpanded(e => !e)}
138 : >
139 1 : {expanded ? <ScreenNormalIcon size={16} /> : <ScreenFullIcon size={16} />}
140 1 : </IconButton>
141 1 : <IconButton
142 1 : variant={ButtonVariant.Invisible}
143 1 : size={ButtonSize.Small}
144 1 : aria-label={`Close ${displayName}`}
145 1 : data-testid="script-editor-dialog-close"
146 1 : onClick={onClose}
147 : >
148 1 : <XIcon size={16} />
149 1 : </IconButton>
150 1 : </div>
151 1 : <div className={styles.body}>
152 1 : <ScriptEditor
153 1 : key={scriptKey}
154 1 : sessionId={sessionId}
155 1 : scriptKey={scriptKey}
156 1 : className={styles.editor}
157 1 : setView={setEditorView}
158 1 : onNavigateToScript={props.onNavigateToScript}
159 1 : />
160 1 : {script.pendingDiff != null && (
161 0 : <ButtonGroup className={styles.diff_actions} aria-label="Accept or reject the rewrite">
162 0 : <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Accept rewrite" data-testid="diff-accept" onClick={acceptDiff}>
163 0 : <CheckIcon size={14} />
164 0 : </IconButton>
165 0 : <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Reject rewrite" data-testid="diff-reject" onClick={rejectDiff}>
166 0 : <XIcon size={14} />
167 0 : </IconButton>
168 0 : </ButtonGroup>
169 : )}
170 1 : </div>
171 1 : {status.kind !== EntryStatusKind.Idle && (
172 1 : <div className={styles.status}>
173 1 : <EntryStatusBar
174 1 : status={status}
175 1 : onCancel={status.indicator === IndicatorStatus.Running ? cancel : undefined}
176 1 : cancelLabel="Cancel query"
177 1 : compact
178 1 : />
179 1 : </div>
180 : )}
181 1 : </section>
182 1 : </Overlay>
183 : );
184 1 : }
|