LCOV - code coverage report
Current view: top level - dashql-app/src/session/snippets - snippets_overlay.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.7 % 232 229
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : 
       4              : import type { EditorView } from '@codemirror/view';
       5              : 
       6              : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
       7              : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
       8              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       9              : import { OverlaySize } from '../../ui/foundations/overlay.js';
      10              : import { DatabaseIcon, FileIcon, LibraryIcon, SearchIcon, XIcon } from '../../ui/foundations/symbol_icon.js';
      11              : import { TextInput } from '../../ui/foundations/text_input.js';
      12              : import { TreeEntry, TreeEntryName } from '../../ui/foundations/tree_entry.js';
      13              : import { useEmbeddedDatabaseSetup } from '../../platform/database/embedded_database_provider.js';
      14              : import { ConnectorType } from '../../connections/connector_info.js';
      15              : import { useDashQLCoreSetup } from '../../runtime/core_provider.js';
      16              : import { ReadonlySqlView, useProjectedSqlText } from '../editor/readonly_sql_view.js';
      17              : import { createScriptFormatConfig, formattingDialectForConnector, getKeywordCase, measureScriptFormatWidth } from '../scripts/script_format.js';
      18              : import { useAppConfig } from '../../config/app_config.js';
      19              : import { SNIPPET_DATASETS_FOLDER, type Snippet } from './snippet.js';
      20              : import { getBundledSnippets, snippetText, snippetsForConnector, type SnippetRegistry } from './snippet_registry.js';
      21              : import { getSnippetSearch } from './snippet_search.js';
      22              : import * as styles from './snippets_overlay.module.css';
      23              : 
      24              : export interface SnippetsOverlayProps {
      25              :     /// The name of the script, for the labels of the trigger
      26              :     scriptName: string;
      27              :     /// The connector of the script, the browser shows the snippets that run on it
      28              :     connectorType: ConnectorType;
      29              :     /// Disable the trigger, e.g. while a rewrite of the script is pending
      30              :     disabled?: boolean;
      31              :     /// Replace the text of the script with the text of a snippet
      32              :     onApply: (text: string) => void;
      33              :     /// The snippets to browse, the bundled ones by default
      34              :     registry?: SnippetRegistry;
      35              :     /// The test id of the trigger
      36              :     testId?: string;
      37              :     /// The size of the library icon of the trigger, 16 by default
      38              :     iconSize?: number;
      39              : }
      40              : 
      41              : /// The registry while the bundled snippets are parsed
      42            1 : const EMPTY_REGISTRY: SnippetRegistry = { snippets: [], byId: new Map(), errors: [] };
      43              : 
      44              : /// The snippets of one category folder
      45              : interface SnippetFolder {
      46              :     /// The category, the name of the folder
      47              :     category: string;
      48              :     /// The snippets in the folder
      49              :     snippets: Snippet[];
      50              : }
      51              : 
      52              : /// The bundled snippets once the core is set up, null until then
      53            1 : function useBundledSnippets(): SnippetRegistry | null {
      54            1 :     const coreSetup = useDashQLCoreSetup();
      55            1 :     const [registry, setRegistry] = React.useState<SnippetRegistry | null>(null);
      56            1 :     React.useEffect(() => {
      57            1 :         if (coreSetup == null) return;
      58            1 :         let cancelled = false;
      59            1 :         coreSetup('snippets').then(core => {
      60            1 :             if (!cancelled) setRegistry(getBundledSnippets(core));
      61            1 :         });
      62            1 :         return () => { cancelled = true; };
      63            1 :     }, [coreSetup]);
      64            1 :     return registry;
      65            1 : }
      66              : 
      67              : /// The snippets that contain a search text
      68              : interface SnippetMatches {
      69              :     /// The ids of the matching snippets, null without a text or before the first search finished
      70              :     ids: ReadonlySet<string> | null;
      71              :     /// The error of the search, null if it succeeded
      72              :     error: string | null;
      73              : }
      74              : 
      75              : /// Search the snippets of a connector in the snippet database of the embedded Hyper.
      76              : /// The matches of a text stay until the search of the next text finished.
      77            1 : function useSnippetMatches(registry: SnippetRegistry, connector: ConnectorType, text: string): SnippetMatches {
      78            1 :     const setupDatabase = useEmbeddedDatabaseSetup();
      79            1 :     const [matches, setMatches] = React.useState<SnippetMatches>({ ids: null, error: null });
      80            1 :     React.useEffect(() => {
      81            1 :         if (text.length === 0) {
      82            1 :             setMatches({ ids: null, error: null });
      83            1 :             return;
      84            1 :         }
      85              :         // Without a provider of the embedded database, e.g. in tests of the browser alone
      86            1 :         if (setupDatabase == null) {
      87            1 :             setMatches({ ids: null, error: 'The search needs the embedded database.' });
      88            1 :             return;
      89            1 :         }
      90            1 :         let cancelled = false;
      91            1 :         setupDatabase('snippets')
      92            1 :             .then(database => getSnippetSearch(database))
      93            1 :             .then(search => search.search(registry, connector, text))
      94            1 :             .then(
      95            1 :                 ids => { if (!cancelled) setMatches({ ids, error: null }); },
      96            0 :                 (error: unknown) => {
      97            0 :                     if (!cancelled) setMatches({ ids: null, error: `The search failed: ${error instanceof Error ? error.message : String(error)}` });
      98            0 :                 },
      99            1 :             );
     100            1 :         return () => { cancelled = true; };
     101            1 :     }, [setupDatabase, registry, connector, text]);
     102            1 :     return text.length === 0 ? { ids: null, error: null } : matches;
     103            1 : }
     104              : 
     105              : /// A library button that opens a browser of SQL snippets, applying a snippet replaces the text of the script
     106            1 : export function SnippetsOverlay(props: SnippetsOverlayProps): React.ReactElement {
     107            1 :     const anchorRef = React.useRef<HTMLButtonElement>(null);
     108            1 :     const listRef = React.useRef<HTMLDivElement>(null);
     109            1 :     const searchRef = React.useRef<HTMLInputElement>(null);
     110            1 :     const [open, setOpen] = React.useState(false);
     111            1 :     const apply = React.useCallback((text: string) => {
     112            1 :         setOpen(false);
     113            1 :         props.onApply(text);
     114            1 :     }, [props.onApply]);
     115              : 
     116            1 :     return (
     117            1 :         <>
     118            1 :             <IconButton
     119            1 :                 ref={anchorRef}
     120            1 :                 variant={open ? ButtonVariant.Default : ButtonVariant.Invisible}
     121            1 :                 size={ButtonSize.Small}
     122            1 :                 aria-label={`Insert a snippet into ${props.scriptName}`}
     123            1 :                 aria-haspopup="dialog"
     124            1 :                 aria-expanded={open}
     125            1 :                 data-testid={props.testId ?? 'script-snippets'}
     126            1 :                 disabled={props.disabled}
     127            1 :                 onClick={(event) => { event.stopPropagation(); setOpen(value => !value); }}
     128              :             >
     129            1 :                 <LibraryIcon size={props.iconSize ?? 16} />
     130            1 :             </IconButton>
     131            1 :             <AnchoredOverlay
     132            1 :                 renderAnchor={null}
     133            1 :                 anchorRef={anchorRef}
     134            1 :                 returnFocusRef={anchorRef}
     135            1 :                 open={open}
     136            1 :                 onClose={() => setOpen(false)}
     137            1 :                 side={AnchorSide.OutsideBottom}
     138            1 :                 align={AnchorAlignment.End}
     139            1 :                 width={OverlaySize.XXL}
     140            1 :                 height={OverlaySize.L}
     141            1 :                 focusTrapSettings={{ initialFocusRef: searchRef as React.RefObject<HTMLElement> }}
     142              :             >
     143            1 :                 <SnippetBrowser
     144            1 :                     connectorType={props.connectorType}
     145            1 :                     registry={props.registry}
     146            1 :                     listRef={listRef}
     147            1 :                     searchRef={searchRef}
     148            1 :                     onApply={apply}
     149            1 :                     onClose={() => setOpen(false)}
     150            1 :                 />
     151            1 :             </AnchoredOverlay>
     152            1 :         </>
     153              :     );
     154            1 : }
     155              : 
     156              : export interface SnippetBrowserProps {
     157              :     /// The connector of the script
     158              :     connectorType: ConnectorType;
     159              :     /// The snippets to browse, the bundled ones by default
     160              :     registry?: SnippetRegistry;
     161              :     /// The snippet list
     162              :     listRef: React.RefObject<HTMLDivElement | null>;
     163              :     /// The search field, focused when the browser opens
     164              :     searchRef?: React.RefObject<HTMLInputElement | null>;
     165              :     /// Apply the text of a snippet
     166              :     onApply: (text: string) => void;
     167              :     /// Close the browser
     168              :     onClose: () => void;
     169              : }
     170              : 
     171              : /// The content of the snippet overlay, mounted while the overlay is open
     172            1 : export function SnippetBrowser(props: SnippetBrowserProps): React.ReactElement {
     173            1 :     const bundled = useBundledSnippets();
     174            1 :     const registry = props.registry ?? bundled ?? EMPTY_REGISTRY;
     175            1 :     const titleId = React.useId();
     176            1 :     const [selectedId, setSelectedId] = React.useState<string | null>(null);
     177            1 :     const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(new Set());
     178            1 :     const [searchText, setSearchText] = React.useState('');
     179            1 :     const searchQuery = searchText.trim();
     180            1 :     const matches = useSnippetMatches(registry, props.connectorType, searchQuery);
     181              : 
     182              :     // Only the snippets that run on the connection of the script and match the search, in the folders of their categories
     183            1 :     const snippets = React.useMemo(() => {
     184            1 :         const all = snippetsForConnector(registry, props.connectorType);
     185            1 :         return matches.ids == null ? all : all.filter(snippet => matches.ids!.has(snippet.id));
     186            1 :     }, [registry, props.connectorType, matches.ids]);
     187              :     // The folders with matches stay open while searching
     188            1 :     const searching = matches.ids != null;
     189            1 :     const folders = React.useMemo(() => {
     190            1 :         const byCategory = new Map<string, SnippetFolder>();
     191            1 :         for (const snippet of snippets) {
     192            1 :             let folder = byCategory.get(snippet.category);
     193            1 :             if (folder == null) {
     194            1 :                 folder = { category: snippet.category, snippets: [] };
     195            1 :                 byCategory.set(snippet.category, folder);
     196            1 :             }
     197            1 :             folder.snippets.push(snippet);
     198            1 :         }
     199            1 :         return [...byCategory.values()];
     200            1 :     }, [snippets]);
     201              :     // The keys move through the snippets of the expanded folders
     202            1 :     const isExpanded = React.useCallback((category: string) => searching || !collapsed.has(category), [searching, collapsed]);
     203            1 :     const visible = React.useMemo(() => folders.flatMap(folder => isExpanded(folder.category) ? folder.snippets : []),
     204            1 :         [folders, isExpanded]);
     205            1 :     const selected = snippets.find(snippet => snippet.id === selectedId) ?? visible[0] ?? null;
     206            1 :     const toggle = React.useCallback((category: string) => setCollapsed(current => {
     207            1 :         const next = new Set(current);
     208            1 :         if (!next.delete(category)) next.add(category);
     209            1 :         return next;
     210            1 :     }), []);
     211            1 :     const text = React.useMemo(() => selected == null ? '' : snippetText(selected, props.connectorType),
     212            1 :         [selected, props.connectorType]);
     213              :     // The preview formats the text to the width of the view, the applied text stays as written
     214            1 :     const keywordCase = getKeywordCase(useAppConfig()?.settings);
     215            1 :     const [formatWidth, setFormatWidth] = React.useState<number | null>(null);
     216            1 :     const onPreviewResize = React.useCallback((view: EditorView) => setFormatWidth(measureScriptFormatWidth(view)), []);
     217            1 :     const formatConfig = React.useMemo(() => formatWidth == null ? null : createScriptFormatConfig(
     218            1 :         dashql.buffers.formatting.FormattingMode.PRETTY, false, formatWidth, keywordCase, formattingDialectForConnector(props.connectorType),
     219            1 :     ), [formatWidth, keywordCase, props.connectorType]);
     220            1 :     const preview = useProjectedSqlText(text, 'snippets', formatConfig);
     221              : 
     222            1 :     const apply = React.useCallback(() => {
     223            1 :         if (selected != null) props.onApply(text);
     224            1 :     }, [selected, text, props.onApply]);
     225              : 
     226              :     // Arrow keys move the selection, Enter applies it. In the list, the focus follows the selection.
     227            1 :     const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLElement>, focusItem: boolean) => {
     228            1 :         if (event.key === 'Enter') {
     229            1 :             event.preventDefault();
     230            1 :             apply();
     231            1 :             return;
     232            1 :         }
     233            1 :         if ((event.key !== 'ArrowDown' && event.key !== 'ArrowUp') || visible.length === 0) return;
     234            1 :         event.preventDefault();
     235            1 :         const index = selected == null ? -1 : visible.indexOf(selected);
     236            1 :         const next = visible[Math.min(Math.max(index + (event.key === 'ArrowDown' ? 1 : -1), 0), visible.length - 1)];
     237            1 :         setSelectedId(next.id);
     238            1 :         const item = props.listRef.current?.querySelector<HTMLElement>(`[data-snippet-id="${next.id}"]`);
     239            1 :         if (focusItem) item?.focus();
     240            1 :         item?.scrollIntoView({ block: 'nearest' });
     241            1 :     }, [apply, visible, selected, props.listRef]);
     242              : 
     243            1 :     return (
     244            1 :         <section className={styles.overlay} role="dialog" aria-labelledby={titleId} data-testid="snippets-overlay">
     245            1 :             <header className={styles.header}>
     246            1 :                 <h2 id={titleId}>Snippets</h2>
     247            1 :                 <IconButton
     248            1 :                     variant={ButtonVariant.Invisible}
     249            1 :                     size={ButtonSize.Small}
     250            1 :                     aria-label="Close snippets"
     251            1 :                     data-testid="snippets-close"
     252            1 :                     onClick={props.onClose}
     253              :                 >
     254            1 :                     <XIcon size={16} />
     255            1 :                 </IconButton>
     256            1 :             </header>
     257            1 :             <div className={styles.body}>
     258            1 :                 <div className={styles.sidebar}>
     259            1 :                     <div className={styles.search}>
     260            1 :                         <TextInput
     261            1 :                             ref={props.searchRef}
     262            1 :                             block
     263            1 :                             type="search"
     264            1 :                             placeholder="Search snippets"
     265            1 :                             aria-label="Search snippets"
     266            1 :                             leadingVisual={SearchIcon}
     267            1 :                             value={searchText}
     268            1 :                             data-testid="snippet-search"
     269            1 :                             onChange={event => setSearchText(event.target.value)}
     270            1 :                             onKeyDown={event => onKeyDown(event, false)}
     271            1 :                         />
     272            1 :                     </div>
     273            1 :                     <div ref={props.listRef} className={styles.list} data-testid="snippet-list" tabIndex={-1} onKeyDown={event => onKeyDown(event, true)}>
     274            1 :                         {matches.error != null ? (
     275            1 :                             <div className={styles.empty} data-testid="snippets-search-error">{matches.error}</div>
     276            1 :                         ) : snippets.length === 0 ? (
     277            1 :                             <div className={styles.empty} data-testid="snippets-empty">
     278            1 :                                 {searching ? 'No snippets match the search.' : 'No snippets for this connection.'}
     279            1 :                             </div>
     280              :                         ) : (
     281            1 :                             <div className={styles.tree}>
     282            1 :                                 {folders.map(folder => {
     283            1 :                                     const expanded = isExpanded(folder.category);
     284            1 :                                     return (
     285            1 :                                         <React.Fragment key={folder.category}>
     286            1 :                                             <TreeEntry
     287            1 :                                                 depth={0}
     288            1 :                                                 expanded={expanded}
     289              :                                                 // The datasets load data, the snippets of the other folders query it
     290            1 :                                                 icon={folder.category === SNIPPET_DATASETS_FOLDER ? <DatabaseIcon size={14} /> : <SearchIcon size={14} />}
     291            1 :                                                 label={<TreeEntryName>{folder.category}</TreeEntryName>}
     292            1 :                                                 data-testid="snippet-folder"
     293            1 :                                                 data-folder={folder.category}
     294            1 :                                                 onClick={() => toggle(folder.category)}
     295            1 :                                             />
     296            1 :                                             {expanded && folder.snippets.map(snippet => {
     297            1 :                                                 const path = snippet.paths.get(props.connectorType)!;
     298            1 :                                                 return (
     299            1 :                                                     <TreeEntry
     300            1 :                                                         key={snippet.id}
     301            1 :                                                         depth={1}
     302            1 :                                                         icon={<FileIcon size={14} />}
     303            1 :                                                         label={<TreeEntryName title={path}>{path.slice(path.lastIndexOf('/') + 1)}</TreeEntryName>}
     304            1 :                                                         selected={snippet === selected}
     305            1 :                                                         data-testid="snippet"
     306            1 :                                                         data-snippet-id={snippet.id}
     307            1 :                                                         onClick={() => setSelectedId(snippet.id)}
     308            1 :                                                         onDoubleClick={() => props.onApply(snippetText(snippet, props.connectorType))}
     309            1 :                                                     />
     310              :                                                 );
     311            1 :                                             })}
     312            1 :                                         </React.Fragment>
     313              :                                     );
     314            1 :                                 })}
     315            1 :                             </div>
     316              :                         )}
     317            1 :                     </div>
     318            1 :                 </div>
     319            1 :                 <div className={styles.preview} data-testid="snippet-preview" data-snippet-id={selected?.id}>
     320            1 :                     {selected != null && (
     321            1 :                         <>
     322            1 :                             <div className={styles.preview_header}>
     323            1 :                                 <Button variant={ButtonVariant.Primary} size={ButtonSize.Small} data-testid="snippet-apply" onClick={apply}>
     324              :                                     Use
     325            1 :                                 </Button>
     326            1 :                             </div>
     327            1 :                             <div className={styles.preview_text} data-testid="snippet-text">
     328            1 :                                 <ReadonlySqlView text={preview.text} editorUpdate={preview.editorUpdate} onResize={onPreviewResize} />
     329            1 :                             </div>
     330            1 :                         </>
     331              :                     )}
     332            1 :                 </div>
     333            1 :             </div>
     334            1 :         </section>
     335              :     );
     336            1 : }
        

Generated by: LCOV version 2.0-1