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