Line data Source code
1 : import * as React from 'react';
2 : import * as dashql from '@ankoh/dashql-core';
3 : import * as themes from '../session/editor/themes/index.js';
4 :
5 : import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
6 : import type { Extension } from '@codemirror/state';
7 : import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view';
8 :
9 : import { CodeMirror } from '../session/editor/codemirror.js';
10 : import {
11 : DashQLScannerDecorationUpdateEffect,
12 : DashQLStandaloneScannerDecorationPlugin,
13 : } from '../session/editor/dashql_decorations_standalone.js';
14 : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
15 : import { useFocusTrap } from '../ui/foundations/focus.js';
16 : import { Overlay, OverlaySize } from '../ui/foundations/overlay.js';
17 : import { AlertIcon, XIcon } from '../ui/foundations/symbol_icon.js';
18 : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
19 : import { buildShareUrl, encodeShareScript } from './shell_share.js';
20 : import * as styles from './share_dialog.module.css';
21 :
22 1 : const IGNORE_OUTSIDE_CLICK = () => {};
23 : /// The URL is encoded once the user pauses typing, deflating the script per keystroke is wasted work
24 1 : const SHARE_URL_DELAY_MS = 150;
25 :
26 : interface ShareResources {
27 : core: dashql.DashQL;
28 : catalog: dashql.DashQLCatalog;
29 : }
30 :
31 : interface PendingRequest {
32 : resolve: () => void;
33 : signal?: AbortSignal;
34 : onAbort?: () => void;
35 : }
36 :
37 : export interface ShareDialogController {
38 : request(core: dashql.DashQL, catalog: dashql.DashQLCatalog, signal?: AbortSignal): Promise<void>;
39 : }
40 :
41 : export interface ShareDialogHookResult {
42 : controller: ShareDialogController;
43 : dialog: React.ReactElement | null;
44 : }
45 :
46 1 : export function useShareDialog(): ShareDialogHookResult {
47 1 : const [resources, setResources] = React.useState<ShareResources | null>(null);
48 1 : const pendingRequestRef = React.useRef<PendingRequest | null>(null);
49 : /// The script of the last dialog, reopening the dialog continues with it
50 1 : const scriptRef = React.useRef('');
51 :
52 1 : const dismiss = React.useCallback(() => {
53 1 : const pending = pendingRequestRef.current;
54 1 : pendingRequestRef.current = null;
55 1 : if (pending?.signal != null && pending.onAbort != null) {
56 1 : pending.signal.removeEventListener('abort', pending.onAbort);
57 : }
58 1 : setResources(null);
59 1 : pending?.resolve();
60 1 : }, []);
61 :
62 1 : const request = React.useCallback((
63 1 : core: dashql.DashQL,
64 1 : catalog: dashql.DashQLCatalog,
65 1 : signal?: AbortSignal,
66 1 : ) => {
67 1 : if (pendingRequestRef.current != null) {
68 1 : return Promise.reject(new Error('A share request is already pending.'));
69 : }
70 1 : if (signal?.aborted) return Promise.resolve();
71 :
72 1 : setResources({ core, catalog });
73 1 : return new Promise<void>(resolve => {
74 1 : const pending: PendingRequest = { resolve, signal };
75 1 : pending.onAbort = dismiss;
76 1 : pendingRequestRef.current = pending;
77 1 : signal?.addEventListener('abort', pending.onAbort, { once: true });
78 1 : });
79 1 : }, [dismiss]);
80 :
81 1 : React.useEffect(() => () => {
82 1 : const pending = pendingRequestRef.current;
83 1 : pendingRequestRef.current = null;
84 1 : if (pending?.signal != null && pending.onAbort != null) {
85 0 : pending.signal.removeEventListener('abort', pending.onAbort);
86 : }
87 1 : pending?.resolve();
88 1 : }, []);
89 :
90 1 : const controller = React.useMemo<ShareDialogController>(() => ({ request }), [request]);
91 1 : return {
92 1 : controller,
93 1 : dialog: resources == null ? null : (
94 1 : <ShareDialog core={resources.core} catalog={resources.catalog} scriptRef={scriptRef} onClose={dismiss} />
95 : ),
96 1 : };
97 1 : }
98 :
99 : interface ShareDialogProps extends ShareResources {
100 : scriptRef: React.RefObject<string>;
101 : onClose: () => void;
102 : }
103 :
104 0 : function errorMessage(error: unknown): string {
105 0 : return error instanceof Error ? error.message : String(error);
106 0 : }
107 :
108 1 : function scanSql(core: dashql.DashQL, catalog: dashql.DashQLCatalog, text: string): dashql.buffers.editor.EditorUpdateT | null {
109 1 : if (text.length === 0) return null;
110 1 : const session = core.createScriptSession(catalog);
111 1 : try {
112 1 : session.replaceText(0n, text);
113 1 : return session.analyze();
114 1 : } catch {
115 0 : return null;
116 1 : } finally {
117 1 : session.destroy();
118 1 : }
119 1 : }
120 :
121 1 : function ShareDialog(props: ShareDialogProps) {
122 1 : const headingId = React.useId();
123 1 : const urlLabelId = React.useId();
124 1 : const dialogRef = React.useRef<HTMLElement>(null);
125 1 : const editorFocusRef = React.useRef<HTMLElement>(null);
126 1 : const editorViewRef = React.useRef<EditorView | null>(null);
127 1 : const initialDocRef = React.useRef(props.scriptRef.current);
128 1 : const textRef = React.useRef(props.scriptRef.current);
129 1 : const onChangeRef = React.useRef<(text: string) => void>(() => {});
130 1 : const updateTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
131 : /// Encoding is asynchronous, only the result of the latest update is shown
132 1 : const updateSequenceRef = React.useRef(0);
133 1 : const [url, setUrl] = React.useState<string | null>(null);
134 1 : const [diagnostic, setDiagnostic] = React.useState<string | null>(null);
135 :
136 1 : const cancelUpdate = React.useCallback(() => {
137 1 : if (updateTimerRef.current != null) {
138 1 : clearTimeout(updateTimerRef.current);
139 1 : updateTimerRef.current = null;
140 1 : }
141 1 : }, []);
142 :
143 : // The core and the catalog belong to the request, never update after the dialog is gone
144 1 : React.useEffect(() => () => {
145 1 : cancelUpdate();
146 1 : updateSequenceRef.current += 1;
147 1 : }, [cancelUpdate]);
148 :
149 : // Update the decorations of the editor and encode the script as URL
150 1 : const runUpdate = React.useCallback(() => {
151 1 : cancelUpdate();
152 1 : const text = textRef.current;
153 1 : const sequence = ++updateSequenceRef.current;
154 1 : const view = editorViewRef.current;
155 1 : if (view != null && view.state.doc.toString() === text) {
156 1 : view.dispatch({ effects: DashQLScannerDecorationUpdateEffect.of(scanSql(props.core, props.catalog, text)) });
157 : }
158 1 : if (text.trim().length === 0) {
159 1 : setUrl(null);
160 1 : setDiagnostic(null);
161 1 : return;
162 1 : }
163 1 : encodeShareScript(text).then(
164 1 : payload => {
165 1 : if (sequence !== updateSequenceRef.current) return;
166 1 : setUrl(buildShareUrl(payload));
167 1 : setDiagnostic(null);
168 1 : },
169 0 : error => {
170 0 : if (sequence !== updateSequenceRef.current) return;
171 0 : setUrl(null);
172 0 : setDiagnostic(errorMessage(error));
173 0 : },
174 1 : );
175 1 : }, [cancelUpdate, props.catalog, props.core]);
176 :
177 1 : onChangeRef.current = (text: string) => {
178 1 : textRef.current = text;
179 1 : props.scriptRef.current = text;
180 1 : cancelUpdate();
181 1 : updateTimerRef.current = setTimeout(runUpdate, SHARE_URL_DELAY_MS);
182 1 : };
183 :
184 1 : const extensions = React.useMemo<Extension[]>(() => [
185 1 : themes.xcode,
186 1 : lineNumbers(),
187 1 : drawSelection(),
188 1 : history(),
189 1 : keymap.of([...defaultKeymap, ...historyKeymap]),
190 1 : ...DashQLStandaloneScannerDecorationPlugin,
191 1 : EditorView.contentAttributes.of({ 'aria-label': 'Shared SQL script editor' }),
192 1 : EditorView.updateListener.of(update => {
193 1 : if (update.docChanged) onChangeRef.current(update.state.doc.toString());
194 1 : }),
195 1 : ], []);
196 :
197 1 : const setView = React.useCallback((view: EditorView | null) => {
198 1 : editorViewRef.current = view;
199 1 : editorFocusRef.current = view?.contentDOM ?? null;
200 1 : if (view != null && textRef.current.length > 0) runUpdate();
201 1 : view?.focus();
202 1 : }, [runUpdate]);
203 :
204 1 : useFocusTrap({
205 1 : containerRef: dialogRef as React.RefObject<HTMLElement>,
206 1 : initialFocusRef: editorFocusRef as React.RefObject<HTMLElement>,
207 1 : restoreFocusOnCleanUp: true,
208 1 : });
209 :
210 1 : return (
211 1 : <Overlay
212 1 : centered
213 1 : width={OverlaySize.XXL}
214 1 : height={OverlaySize.XL}
215 1 : maxHeight={OverlaySize.XL}
216 1 : preventFocusOnOpen
217 1 : onEscape={props.onClose}
218 1 : onClickOutside={IGNORE_OUTSIDE_CLICK}
219 : >
220 1 : <section
221 1 : ref={dialogRef}
222 1 : className={styles.dialog}
223 1 : role="dialog"
224 1 : aria-modal="true"
225 1 : aria-labelledby={headingId}
226 : >
227 1 : <header className={styles.header}>
228 1 : <h2 id={headingId} className={styles.title}>Share Script</h2>
229 1 : <div className={styles.spacer} />
230 1 : {diagnostic != null && (
231 0 : <div className={styles.diagnostic} role="alert" title={diagnostic}>
232 0 : <AlertIcon size={16} aria-hidden="true" />
233 0 : <span className={styles.diagnostic_text}>{diagnostic}</span>
234 0 : </div>
235 : )}
236 1 : <IconButton
237 1 : variant={ButtonVariant.Invisible}
238 1 : size={ButtonSize.Small}
239 1 : aria-label="Close share dialog"
240 1 : data-testid="dialog-close"
241 1 : onClick={props.onClose}
242 : >
243 1 : <XIcon size={16} />
244 1 : </IconButton>
245 1 : </header>
246 1 : <div className={styles.url_bar}>
247 1 : <span id={urlLabelId} className={styles.url_label}>URL</span>
248 1 : <output
249 1 : className={url == null ? styles.url_placeholder : styles.url}
250 1 : aria-labelledby={urlLabelId}
251 1 : data-testid="share-url"
252 1 : title={url ?? undefined}
253 : >
254 1 : {url ?? 'Enter SQL to generate a link'}
255 1 : </output>
256 1 : <span className={styles.url_length} aria-label="URL length" data-testid="share-url-length">
257 1 : {url?.length ?? 0} characters
258 1 : </span>
259 1 : <CopyToClipboardButton
260 1 : variant={ButtonVariant.Invisible}
261 1 : size={ButtonSize.Small}
262 1 : value={url ?? ''}
263 1 : disabled={url == null}
264 1 : aria-label="Copy share URL"
265 1 : data-testid="share-url-copy"
266 1 : aria-labelledby=""
267 1 : logContext="shell_share"
268 1 : />
269 1 : </div>
270 1 : <div className={styles.editor}>
271 1 : <CodeMirror ref={setView} extensions={extensions} initialDoc={initialDocRef.current} />
272 1 : </div>
273 1 : </section>
274 1 : </Overlay>
275 : );
276 1 : }
|