LCOV - code coverage report
Current view: top level - dashql-app/src/shell_app - share_dialog.tsx Coverage Total Hit
Test: DashQL 6e74948 Lines: 92.9 % 197 183
Test Date: 2026-11-06 17:46:50

            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 : }
        

Generated by: LCOV version 2.0-1