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, FileDirectoryIcon, 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 bounds of the width of the snippet list, the preview keeps the rest
45 1 : const LIST_WIDTH_DEFAULT = 300;
46 1 : const LIST_WIDTH_MIN = 180;
47 1 : const PREVIEW_WIDTH_MIN = 320;
48 1 : const LIST_WIDTH_KEY_STEP = 16;
49 :
50 : /// The width of the snippet list and the handlers of the handle on its border that resizes it
51 1 : function useListWidth(bodyRef: React.RefObject<HTMLDivElement | null>) {
52 1 : const [width, setWidthState] = React.useState(LIST_WIDTH_DEFAULT);
53 1 : const maxWidth = React.useCallback(() =>
54 1 : Math.max(LIST_WIDTH_MIN, (bodyRef.current?.getBoundingClientRect().width ?? 0) - PREVIEW_WIDTH_MIN), [bodyRef]);
55 1 : const setWidth = React.useCallback((next: number) =>
56 1 : setWidthState(Math.round(Math.min(Math.max(next, LIST_WIDTH_MIN), maxWidth()))), [maxWidth]);
57 1 : const stopDraggingRef = React.useRef<(() => void) | null>(null);
58 1 : React.useEffect(() => () => stopDraggingRef.current?.(), []);
59 :
60 1 : const onPointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
61 1 : if (event.button !== 0) return;
62 1 : event.preventDefault();
63 1 : stopDraggingRef.current?.();
64 1 : const pointerId = event.pointerId;
65 1 : const left = bodyRef.current?.getBoundingClientRect().left ?? 0;
66 1 : const onMove = (move: PointerEvent) => {
67 1 : if (move.pointerId === pointerId) setWidth(move.clientX - left);
68 1 : };
69 1 : const onEnd = (end: PointerEvent) => {
70 1 : if (end.pointerId === pointerId) stop();
71 1 : };
72 1 : const stop = () => {
73 1 : window.removeEventListener('pointermove', onMove);
74 1 : window.removeEventListener('pointerup', onEnd);
75 1 : window.removeEventListener('pointercancel', onEnd);
76 1 : stopDraggingRef.current = null;
77 1 : };
78 1 : window.addEventListener('pointermove', onMove);
79 1 : window.addEventListener('pointerup', onEnd);
80 1 : window.addEventListener('pointercancel', onEnd);
81 1 : stopDraggingRef.current = stop;
82 1 : }, [bodyRef, setWidth]);
83 :
84 1 : const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
85 1 : switch (event.key) {
86 0 : case 'ArrowLeft': setWidth(width - LIST_WIDTH_KEY_STEP); break;
87 0 : case 'ArrowRight': setWidth(width + LIST_WIDTH_KEY_STEP); break;
88 0 : case 'Home': setWidth(LIST_WIDTH_MIN); break;
89 1 : case 'End': setWidth(maxWidth()); break;
90 0 : default: return;
91 1 : }
92 1 : event.preventDefault();
93 1 : }, [setWidth, maxWidth, width]);
94 1 : return { width, onPointerDown, onKeyDown };
95 1 : }
96 :
97 : /// A folder of the snippet tree
98 : interface SnippetFolder {
99 : /// The path of the folder, it identifies the folder. Empty for the root of the tree.
100 : path: string;
101 : /// The name of the folder
102 : name: string;
103 : /// The folders in the folder, in the order of their first snippets
104 : folders: SnippetFolder[];
105 : /// The snippets in the folder
106 : snippets: Snippet[];
107 : }
108 :
109 : /// Build the tree of the folders of snippets, it keeps the order of the snippets
110 1 : function buildSnippetTree(snippets: readonly Snippet[]): SnippetFolder {
111 1 : const root: SnippetFolder = { path: '', name: '', folders: [], snippets: [] };
112 1 : const byPath = new Map<string, SnippetFolder>([['', root]]);
113 1 : const folderOf = (path: string): SnippetFolder => {
114 1 : let folder = byPath.get(path);
115 1 : if (folder == null) {
116 1 : const slash = path.lastIndexOf('/');
117 1 : folder = { path, name: path.slice(slash + 1), folders: [], snippets: [] };
118 1 : byPath.set(path, folder);
119 1 : folderOf(slash < 0 ? '' : path.slice(0, slash)).folders.push(folder);
120 1 : }
121 1 : return folder;
122 1 : };
123 1 : for (const snippet of snippets) {
124 1 : folderOf(snippet.category).snippets.push(snippet);
125 : }
126 1 : return root;
127 1 : }
128 :
129 : /// The snippets of the expanded folders below a folder as the tree lists them, the folders before the files
130 1 : function expandedSnippets(folder: SnippetFolder, isExpanded: (path: string) => boolean): Snippet[] {
131 1 : return [
132 1 : ...folder.folders.flatMap(child => isExpanded(child.path) ? expandedSnippets(child, isExpanded) : []),
133 1 : ...folder.snippets,
134 1 : ];
135 1 : }
136 :
137 : /// The bundled snippets once the core is set up, null until then
138 1 : function useBundledSnippets(): SnippetRegistry | null {
139 1 : const coreSetup = useDashQLCoreSetup();
140 1 : const [registry, setRegistry] = React.useState<SnippetRegistry | null>(null);
141 1 : React.useEffect(() => {
142 1 : if (coreSetup == null) return;
143 1 : let cancelled = false;
144 1 : coreSetup('snippets').then(core => {
145 1 : if (!cancelled) setRegistry(getBundledSnippets(core));
146 1 : });
147 1 : return () => { cancelled = true; };
148 1 : }, [coreSetup]);
149 1 : return registry;
150 1 : }
151 :
152 : /// The snippets that contain a search text
153 : interface SnippetMatches {
154 : /// The ids of the matching snippets, null without a text or before the first search finished
155 : ids: ReadonlySet<string> | null;
156 : /// The error of the search, null if it succeeded
157 : error: string | null;
158 : }
159 :
160 : /// Search the snippets of a connector in the snippet database of the embedded Hyper.
161 : /// The matches of a text stay until the search of the next text finished.
162 1 : function useSnippetMatches(registry: SnippetRegistry, connector: ConnectorType, text: string): SnippetMatches {
163 1 : const setupDatabase = useEmbeddedDatabaseSetup();
164 1 : const [matches, setMatches] = React.useState<SnippetMatches>({ ids: null, error: null });
165 1 : React.useEffect(() => {
166 1 : if (text.length === 0) {
167 1 : setMatches({ ids: null, error: null });
168 1 : return;
169 1 : }
170 : // Without a provider of the embedded database, e.g. in tests of the browser alone
171 1 : if (setupDatabase == null) {
172 1 : setMatches({ ids: null, error: 'The search needs the embedded database.' });
173 1 : return;
174 1 : }
175 1 : let cancelled = false;
176 1 : setupDatabase('snippets')
177 1 : .then(database => getSnippetSearch(database))
178 1 : .then(search => search.search(registry, connector, text))
179 1 : .then(
180 1 : ids => { if (!cancelled) setMatches({ ids, error: null }); },
181 0 : (error: unknown) => {
182 0 : if (!cancelled) setMatches({ ids: null, error: `The search failed: ${error instanceof Error ? error.message : String(error)}` });
183 0 : },
184 1 : );
185 1 : return () => { cancelled = true; };
186 1 : }, [setupDatabase, registry, connector, text]);
187 1 : return text.length === 0 ? { ids: null, error: null } : matches;
188 1 : }
189 :
190 : /// A library button that opens a browser of SQL snippets, applying a snippet replaces the text of the script
191 1 : export function SnippetsOverlay(props: SnippetsOverlayProps): React.ReactElement {
192 1 : const anchorRef = React.useRef<HTMLButtonElement>(null);
193 1 : const listRef = React.useRef<HTMLDivElement>(null);
194 1 : const searchRef = React.useRef<HTMLInputElement>(null);
195 1 : const [open, setOpen] = React.useState(false);
196 1 : const apply = React.useCallback((text: string) => {
197 1 : setOpen(false);
198 1 : props.onApply(text);
199 1 : }, [props.onApply]);
200 :
201 1 : return (
202 1 : <>
203 1 : <IconButton
204 1 : ref={anchorRef}
205 1 : variant={open ? ButtonVariant.Default : ButtonVariant.Invisible}
206 1 : size={ButtonSize.Small}
207 1 : aria-label={`Insert a snippet into ${props.scriptName}`}
208 1 : aria-haspopup="dialog"
209 1 : aria-expanded={open}
210 1 : data-testid={props.testId ?? 'script-snippets'}
211 1 : disabled={props.disabled}
212 1 : onClick={(event) => { event.stopPropagation(); setOpen(value => !value); }}
213 : >
214 1 : <LibraryIcon size={props.iconSize ?? 16} />
215 1 : </IconButton>
216 1 : <AnchoredOverlay
217 1 : renderAnchor={null}
218 1 : anchorRef={anchorRef}
219 1 : returnFocusRef={anchorRef}
220 1 : open={open}
221 1 : onClose={() => setOpen(false)}
222 1 : side={AnchorSide.OutsideBottom}
223 1 : align={AnchorAlignment.End}
224 1 : width={OverlaySize.XXL}
225 1 : height={OverlaySize.L}
226 1 : focusTrapSettings={{ initialFocusRef: searchRef as React.RefObject<HTMLElement> }}
227 : >
228 1 : <SnippetBrowser
229 1 : connectorType={props.connectorType}
230 1 : registry={props.registry}
231 1 : listRef={listRef}
232 1 : searchRef={searchRef}
233 1 : onApply={apply}
234 1 : onClose={() => setOpen(false)}
235 1 : />
236 1 : </AnchoredOverlay>
237 1 : </>
238 : );
239 1 : }
240 :
241 : export interface SnippetBrowserProps {
242 : /// The connector of the script
243 : connectorType: ConnectorType;
244 : /// The snippets to browse, the bundled ones by default
245 : registry?: SnippetRegistry;
246 : /// The snippet list
247 : listRef: React.RefObject<HTMLDivElement | null>;
248 : /// The search field, focused when the browser opens
249 : searchRef?: React.RefObject<HTMLInputElement | null>;
250 : /// Apply the text of a snippet
251 : onApply: (text: string) => void;
252 : /// Close the browser
253 : onClose: () => void;
254 : }
255 :
256 : /// The content of the snippet overlay, mounted while the overlay is open
257 1 : export function SnippetBrowser(props: SnippetBrowserProps): React.ReactElement {
258 1 : const bundled = useBundledSnippets();
259 1 : const registry = props.registry ?? bundled ?? EMPTY_REGISTRY;
260 1 : const titleId = React.useId();
261 1 : const bodyRef = React.useRef<HTMLDivElement>(null);
262 1 : const listWidth = useListWidth(bodyRef);
263 1 : const [selectedId, setSelectedId] = React.useState<string | null>(null);
264 1 : const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(new Set());
265 1 : const [searchText, setSearchText] = React.useState('');
266 1 : const searchQuery = searchText.trim();
267 1 : const matches = useSnippetMatches(registry, props.connectorType, searchQuery);
268 :
269 : // Only the snippets that run on the connection of the script and match the search
270 1 : const snippets = React.useMemo(() => {
271 1 : const all = snippetsForConnector(registry, props.connectorType);
272 1 : return matches.ids == null ? all : all.filter(snippet => matches.ids!.has(snippet.id));
273 1 : }, [registry, props.connectorType, matches.ids]);
274 : // The folders with matches stay open while searching
275 1 : const searching = matches.ids != null;
276 1 : const tree = React.useMemo(() => buildSnippetTree(snippets), [snippets]);
277 : // The keys move through the snippets of the expanded folders
278 1 : const isExpanded = React.useCallback((path: string) => searching || !collapsed.has(path), [searching, collapsed]);
279 1 : const visible = React.useMemo(() => expandedSnippets(tree, isExpanded), [tree, isExpanded]);
280 1 : const selected = snippets.find(snippet => snippet.id === selectedId) ?? visible[0] ?? null;
281 1 : const toggle = React.useCallback((path: string) => setCollapsed(current => {
282 1 : const next = new Set(current);
283 1 : if (!next.delete(path)) next.add(path);
284 1 : return next;
285 1 : }), []);
286 1 : const text = React.useMemo(() => selected == null ? '' : snippetText(selected, props.connectorType),
287 1 : [selected, props.connectorType]);
288 : // The preview formats the text to the width of the view, the applied text stays as written
289 1 : const keywordCase = getKeywordCase(useAppConfig()?.settings);
290 1 : const [formatWidth, setFormatWidth] = React.useState<number | null>(null);
291 1 : const onPreviewResize = React.useCallback((view: EditorView) => setFormatWidth(measureScriptFormatWidth(view)), []);
292 1 : const formatConfig = React.useMemo(() => formatWidth == null ? null : createScriptFormatConfig(
293 1 : dashql.buffers.formatting.FormattingMode.PRETTY, false, formatWidth, keywordCase, formattingDialectForConnector(props.connectorType),
294 1 : ), [formatWidth, keywordCase, props.connectorType]);
295 1 : const preview = useProjectedSqlText(text, 'snippets', formatConfig);
296 :
297 1 : const apply = React.useCallback(() => {
298 1 : if (selected != null) props.onApply(text);
299 1 : }, [selected, text, props.onApply]);
300 :
301 : // Arrow keys move the selection, Enter applies it. In the list, the focus follows the selection.
302 1 : const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLElement>, focusItem: boolean) => {
303 1 : if (event.key === 'Enter') {
304 1 : event.preventDefault();
305 1 : apply();
306 1 : return;
307 1 : }
308 1 : if ((event.key !== 'ArrowDown' && event.key !== 'ArrowUp') || visible.length === 0) return;
309 1 : event.preventDefault();
310 1 : const index = selected == null ? -1 : visible.indexOf(selected);
311 1 : const next = visible[Math.min(Math.max(index + (event.key === 'ArrowDown' ? 1 : -1), 0), visible.length - 1)];
312 1 : setSelectedId(next.id);
313 1 : const item = props.listRef.current?.querySelector<HTMLElement>(`[data-snippet-id="${next.id}"]`);
314 1 : if (focusItem) item?.focus();
315 1 : item?.scrollIntoView({ block: 'nearest' });
316 1 : }, [apply, visible, selected, props.listRef]);
317 :
318 1 : const renderEntries = (parent: SnippetFolder, depth: number): React.ReactNode => (
319 1 : <>
320 1 : {parent.folders.map(folder => {
321 1 : const expanded = isExpanded(folder.path);
322 1 : return (
323 1 : <React.Fragment key={folder.path}>
324 1 : <TreeEntry
325 1 : depth={depth}
326 1 : expanded={expanded}
327 : // The datasets load the data that the snippets of the other folders use
328 1 : icon={folder.path === SNIPPET_DATASETS_FOLDER ? <DatabaseIcon size={14} /> : <FileDirectoryIcon size={14} />}
329 1 : label={<TreeEntryName title={folder.path}>{folder.name}</TreeEntryName>}
330 1 : data-testid="snippet-folder"
331 1 : data-folder={folder.path}
332 1 : onClick={() => toggle(folder.path)}
333 1 : />
334 1 : {expanded && renderEntries(folder, depth + 1)}
335 1 : </React.Fragment>
336 : );
337 1 : })}
338 1 : {parent.snippets.map(snippet => {
339 1 : const path = snippet.paths.get(props.connectorType)!;
340 1 : return (
341 1 : <TreeEntry
342 1 : key={snippet.id}
343 1 : depth={depth}
344 1 : icon={<FileIcon size={14} />}
345 1 : label={<TreeEntryName title={path}>{path.slice(path.lastIndexOf('/') + 1)}</TreeEntryName>}
346 1 : selected={snippet === selected}
347 1 : data-testid="snippet"
348 1 : data-snippet-id={snippet.id}
349 1 : onClick={() => setSelectedId(snippet.id)}
350 1 : onDoubleClick={() => props.onApply(snippetText(snippet, props.connectorType))}
351 1 : />
352 : );
353 1 : })}
354 1 : </>
355 : );
356 :
357 1 : return (
358 1 : <section className={styles.overlay} role="dialog" aria-labelledby={titleId} data-testid="snippets-overlay">
359 1 : <header className={styles.header}>
360 1 : <h2 id={titleId}>Snippets</h2>
361 1 : <IconButton
362 1 : variant={ButtonVariant.Invisible}
363 1 : size={ButtonSize.Small}
364 1 : aria-label="Close snippets"
365 1 : data-testid="snippets-close"
366 1 : onClick={props.onClose}
367 : >
368 1 : <XIcon size={16} />
369 1 : </IconButton>
370 1 : </header>
371 1 : <div ref={bodyRef} className={styles.body} style={{ gridTemplateColumns: `${listWidth.width}px minmax(0, 1fr)` }}>
372 1 : <div className={styles.sidebar}>
373 1 : <div
374 1 : className={styles.resize_handle}
375 1 : role="separator"
376 1 : aria-orientation="vertical"
377 1 : aria-label="Resize the snippet list"
378 1 : aria-valuenow={listWidth.width}
379 1 : tabIndex={0}
380 1 : data-testid="snippet-list-resize"
381 1 : onPointerDown={listWidth.onPointerDown}
382 1 : onKeyDown={listWidth.onKeyDown}
383 1 : />
384 1 : <div className={styles.search}>
385 1 : <TextInput
386 1 : ref={props.searchRef}
387 1 : block
388 1 : type="search"
389 1 : placeholder="Search snippets"
390 1 : aria-label="Search snippets"
391 1 : leadingVisual={SearchIcon}
392 1 : value={searchText}
393 1 : data-testid="snippet-search"
394 1 : onChange={event => setSearchText(event.target.value)}
395 1 : onKeyDown={event => onKeyDown(event, false)}
396 1 : />
397 1 : </div>
398 1 : <div ref={props.listRef} className={styles.list} data-testid="snippet-list" tabIndex={-1} onKeyDown={event => onKeyDown(event, true)}>
399 1 : {matches.error != null ? (
400 1 : <div className={styles.empty} data-testid="snippets-search-error">{matches.error}</div>
401 1 : ) : snippets.length === 0 ? (
402 1 : <div className={styles.empty} data-testid="snippets-empty">
403 1 : {searching ? 'No snippets match the search.' : 'No snippets for this connection.'}
404 1 : </div>
405 : ) : (
406 1 : <div className={styles.tree}>{renderEntries(tree, 0)}</div>
407 : )}
408 1 : </div>
409 1 : </div>
410 1 : <div className={styles.preview} data-testid="snippet-preview" data-snippet-id={selected?.id}>
411 1 : {selected != null && (
412 1 : <>
413 1 : <div className={styles.preview_header}>
414 1 : <Button variant={ButtonVariant.Primary} size={ButtonSize.Small} data-testid="snippet-apply" onClick={apply}>
415 : Use
416 1 : </Button>
417 1 : </div>
418 1 : <div className={styles.preview_text} data-testid="snippet-text">
419 1 : <ReadonlySqlView text={preview.text} editorUpdate={preview.editorUpdate} onResize={onPreviewResize} />
420 1 : </div>
421 1 : </>
422 : )}
423 1 : </div>
424 1 : </div>
425 1 : </section>
426 : );
427 1 : }
|