• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dataunitylab / relational-playground / #109

10 Sep 2025 06:35PM UTC coverage: 78.51% (+0.1%) from 78.407%
#109

push

michaelmior
Fix SqlEditor test for React context refactoring

Signed-off-by: Michael Mior <mmior@mail.rit.edu>

526 of 743 branches covered (70.79%)

Branch coverage included in aggregate %.

1034 of 1244 relevant lines covered (83.12%)

14373.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

30.2
/src/SqlEditor.js
1
// @flow
2
import * as React from 'react';
3
import Editor from 'react-simple-code-editor';
4
import {highlight, languages} from 'prismjs/components/prism-core';
5
import 'prismjs/components/prism-sql';
6
import {exprFromSql} from './modules/relexp';
7
import {resetAction} from './modules/data';
8
import SqlAutocompleteDropdown, {
9
  parseForAutocomplete,
10
  generateSuggestions,
11
} from './SqlAutocomplete';
12
import {useReactGA} from './contexts/ReactGAContext';
13
import './SqlEditor.css';
14

15
import 'prismjs/themes/prism.css';
16

17
import type {Node, StatelessFunctionalComponent} from 'react';
18

19
const parser = require('@michaelmior/js-sql-parser');
2✔
20

21
type Props = {
22
  navigate: any,
23
  defaultText: string,
24
  exprFromSql: typeof exprFromSql,
25
  resetAction: typeof resetAction,
26
  ReactGA: any,
27
  types: {[string]: Array<string>},
28
};
29

30
type State = {
31
  error: string | null,
32
  timeout: any,
33
  query: string,
34
  showAutocomplete: boolean,
35
  autocompleteItems: Array<any>,
36
  selectedIndex: number,
37
  autocompletePosition: {top: number, left: number},
38
  cursorPosition: number,
39
};
40

41
/** Editor for SQL queries */
42
class SqlEditor extends React.Component<Props, State> {
43
  editorRef: {current: null | HTMLElement};
44
  isAutocompleting: boolean;
45

46
  constructor() {
47
    super();
3✔
48
    // $FlowFixMe[method-unbinding]
49
    (this: any).handleChange = this.handleChange.bind(this);
3✔
50
    // $FlowFixMe[method-unbinding]
51
    (this: any).parseQuery = this.parseQuery.bind(this);
3✔
52
    // $FlowFixMe[method-unbinding]
53
    (this: any).handleKeyDown = this.handleKeyDown.bind(this);
3✔
54
    // $FlowFixMe[method-unbinding]
55
    (this: any).handleAutocompleteSelect =
3✔
56
      // $FlowFixMe[method-unbinding]
57
      this.handleAutocompleteSelect.bind(this);
58
    // $FlowFixMe[method-unbinding]
59
    (this: any).hideAutocomplete = this.hideAutocomplete.bind(this);
3✔
60

61
    this.editorRef = React.createRef();
3✔
62
    this.isAutocompleting = false;
3✔
63
    this.state = {
3✔
64
      error: null,
65
      timeout: null,
66
      query: '',
67
      showAutocomplete: false,
68
      autocompleteItems: [],
69
      selectedIndex: 0,
70
      autocompletePosition: {top: 0, left: 0},
71
      cursorPosition: 0,
72
    };
73
  }
74

75
  componentDidMount() {
76
    // Parse the initial query when we start
77
    const values = new URL(window.location.toString()).searchParams;
3✔
78
    const query = values.get('query');
3✔
79

80
    if (query) {
3!
81
      this.parseQuery(query, false);
×
82
      this.setState({query: query});
×
83
    } else {
84
      this.parseQuery(this.props.defaultText, true);
3✔
85
      this.setState({query: this.props.defaultText});
3✔
86
    }
87
  }
88

89
  /**
90
   * @param text - the query to parse (optional - if not provided, uses current state)
91
   * @param firstLoad - whether this is the first call when mounted
92
   */
93
  parseQuery(text?: string, firstLoad?: boolean): void {
94
    // Always use the current state query unless explicitly overridden
95
    const queryToParse = text !== undefined ? text : this.state.query;
4✔
96

97
    if (!firstLoad) {
4✔
98
      if (this.props.resetAction) {
1!
99
        this.props.resetAction();
1✔
100
      }
101
      this.setState({timeout: null});
1✔
102
    }
103
    try {
4✔
104
      const sql = parser.parse(queryToParse);
4✔
105
      if (
4!
106
        sql.nodeType === 'Main' &&
8✔
107
        ['Except', 'Intersect', 'Select', 'Union'].includes(sql.value.type)
108
      ) {
109
        // Record the typed SQL statement
110
        if (!firstLoad && this.props.ReactGA) {
4✔
111
          this.props.ReactGA.event({
1✔
112
            category: 'User Typing SQL Statement',
113
            action: queryToParse,
114
          });
115
        }
116

117
        // Parse the query
118
        this.props.exprFromSql(sql.value, this.props.types);
4✔
119
        this.props.navigate('/?query=' + queryToParse);
4✔
120

121
        if (!firstLoad) {
4✔
122
          this.setState({error: null});
1✔
123
        }
124
      } else {
125
        // Show an error if we try to parse any unsupported query type
126
        const errMsg = 'Unsupported expression';
×
127
        if (!firstLoad) {
×
128
          this.setState({error: errMsg});
×
129
        }
130
      }
131
    } catch (err) {
132
      // Display any error message generated during parsing
133
      if (!firstLoad) {
×
134
        this.setState({error: err.message});
×
135
      }
136
    }
137
  }
138

139
  handleChange(text: string): void {
140
    // If we're in the middle of autocomplete, don't trigger parsing or autocomplete - just update the state
141
    if (this.isAutocompleting) {
1!
142
      this.setState({query: text});
×
143
      return;
×
144
    }
145

146
    // Cancel any pending query parsing
147
    if (this.state.timeout) {
1!
148
      clearTimeout(this.state.timeout);
×
149
    }
150

151
    // Only parse the query once per second
152
    let handle = setTimeout(() => {
1✔
153
      // Use current state instead of captured closure variable
154
      this.parseQuery();
1✔
155
    }, 1000);
156

157
    this.setState({timeout: handle, query: text}, () => {
1✔
158
      // Update autocomplete after state is set with a small delay to ensure cursor position is updated
159
      setTimeout(() => {
1✔
160
        this.updateAutocomplete(text);
1✔
161
      }, 50);
162
    });
163
  }
164

165
  updateAutocomplete(text: string): void {
166
    // Check if we're still in autocompleting mode
167
    if (this.isAutocompleting) {
1!
168
      return;
×
169
    }
170

171
    // Get cursor position from the textarea
172
    const textarea = document.getElementById('sqlInput');
1✔
173
    if (
1!
174
      !textarea ||
1!
175
      !(
176
        textarea instanceof HTMLInputElement ||
×
177
        textarea instanceof HTMLTextAreaElement
178
      ) ||
179
      typeof textarea.selectionStart !== 'number'
180
    ) {
181
      this.hideAutocomplete();
1✔
182
      return;
1✔
183
    }
184

185
    const cursorPosition = textarea.selectionStart;
×
186
    const parseResult = parseForAutocomplete(text, cursorPosition);
×
187

188
    if (!parseResult.context || !parseResult.prefix) {
×
189
      this.hideAutocomplete();
×
190
      return;
×
191
    }
192

193
    const suggestions = generateSuggestions(
×
194
      parseResult.prefix,
195
      parseResult.context,
196
      this.props.types,
197
      parseResult.needsTablePrefix
198
    );
199

200
    if (suggestions.length === 0) {
×
201
      this.hideAutocomplete();
×
202
      return;
×
203
    }
204

205
    // Scenario 1: Check if the user has typed exactly one of the suggestions
206
    if (
×
207
      suggestions.length === 1 &&
×
208
      suggestions[0].text === parseResult.prefix
209
    ) {
210
      this.hideAutocomplete();
×
211
      return;
×
212
    }
213

214
    // Also hide if the prefix exactly matches any suggestion (case-insensitive)
215
    const exactMatch = suggestions.find(
×
216
      (suggestion) =>
217
        suggestion.text.toLowerCase() === parseResult.prefix.toLowerCase()
×
218
    );
219
    if (exactMatch) {
×
220
      this.hideAutocomplete();
×
221
      return;
×
222
    }
223

224
    // Calculate position for dropdown using the actual textarea element
225
    const rect = textarea.getBoundingClientRect();
×
226
    const position = this.calculateDropdownPosition(
×
227
      textarea,
228
      cursorPosition,
229
      rect
230
    );
231

232
    this.setState({
×
233
      showAutocomplete: true,
234
      autocompleteItems: suggestions,
235
      selectedIndex: 0,
236
      autocompletePosition: position,
237
      cursorPosition: cursorPosition, // Update the stored cursor position
238
    });
239
  }
240

241
  calculateDropdownPosition(
242
    textarea: any,
243
    cursorPosition: number,
244
    rect: ClientRect
245
  ): {top: number, left: number} {
246
    // Get text up to cursor position
247
    const textBeforeCursor = textarea.value.slice(0, cursorPosition);
×
248
    const lines = textBeforeCursor.split('\n');
×
249
    const currentLine = lines.length - 1;
×
250
    const currentLineText = lines[currentLine] || '';
×
251

252
    // Get the computed style of the textarea
253
    const computedStyle = getComputedStyle(textarea);
×
254
    const fontSize = parseFloat(computedStyle.fontSize) || 14;
×
255
    const lineHeight = parseFloat(computedStyle.lineHeight) || fontSize * 1.4;
×
256

257
    // Measure actual character width using a temporary element
258
    let actualCharWidth = fontSize * 0.6; // fallback
×
259
    const body = document.body;
×
260
    if (body) {
×
261
      const measurer = document.createElement('span');
×
262
      measurer.style.fontFamily = computedStyle.fontFamily;
×
263
      measurer.style.fontSize = computedStyle.fontSize;
×
264
      measurer.style.fontWeight = computedStyle.fontWeight;
×
265
      measurer.style.letterSpacing = computedStyle.letterSpacing;
×
266
      measurer.style.visibility = 'hidden';
×
267
      measurer.style.position = 'absolute';
×
268
      measurer.style.whiteSpace = 'pre';
×
269
      measurer.textContent = currentLineText;
×
270

271
      body.appendChild(measurer);
×
272
      const actualTextWidth = measurer.offsetWidth;
×
273
      body.removeChild(measurer);
×
274

275
      if (currentLineText.length > 0) {
×
276
        actualCharWidth = actualTextWidth / currentLineText.length;
×
277
      }
278
    }
279

280
    // Get padding and border values
281
    const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
×
282
    const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
×
283
    const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
×
284
    const borderLeft = parseFloat(computedStyle.borderLeftWidth) || 0;
×
285

286
    // Calculate the cursor position, then move dropdown below the current line
287
    const cursorTop =
288
      rect.top +
×
289
      paddingTop +
290
      borderTop +
291
      currentLine * lineHeight +
292
      lineHeight +
293
      window.scrollY;
294
    const cursorLeft =
295
      rect.left +
×
296
      paddingLeft +
297
      borderLeft +
298
      currentLineText.length * actualCharWidth +
299
      window.scrollX;
300

301
    return {
×
302
      top: cursorTop,
303
      left: cursorLeft,
304
    };
305
  }
306

307
  handleKeyDown(event: KeyboardEvent): boolean {
308
    if (!this.state.showAutocomplete) {
×
309
      return false;
×
310
    }
311

312
    switch (event.key) {
×
313
      case 'ArrowDown':
314
        event.preventDefault();
×
315
        this.setState((prevState) => ({
×
316
          selectedIndex:
317
            (prevState.selectedIndex + 1) % prevState.autocompleteItems.length,
318
        }));
319
        return true;
×
320

321
      case 'ArrowUp':
322
        event.preventDefault();
×
323
        this.setState((prevState) => ({
×
324
          selectedIndex:
325
            prevState.selectedIndex === 0
×
326
              ? prevState.autocompleteItems.length - 1
327
              : prevState.selectedIndex - 1,
328
        }));
329
        return true;
×
330

331
      case 'Tab':
332
      case 'Enter':
333
        event.preventDefault();
×
334
        this.handleAutocompleteSelect(
×
335
          this.state.autocompleteItems[this.state.selectedIndex]
336
        );
337
        return true;
×
338

339
      case 'Escape':
340
        this.hideAutocomplete();
×
341
        return true;
×
342

343
      default:
344
        return false;
×
345
    }
346
  }
347

348
  handleAutocompleteSelect(item: any): void {
349
    const {query, cursorPosition: storedCursorPosition} = this.state;
×
350

351
    // Set the class property to prevent handleChange from interfering
352
    this.isAutocompleting = true;
×
353

354
    // Cancel any pending query parsing from handleChange
355
    if (this.state.timeout) {
×
356
      clearTimeout(this.state.timeout);
×
357
    }
358

359
    // Get the current cursor position from the actual textarea element
360
    const textarea = document.getElementById('sqlInput');
×
361
    if (
×
362
      !textarea ||
×
363
      !(
364
        textarea instanceof HTMLInputElement ||
×
365
        textarea instanceof HTMLTextAreaElement
366
      ) ||
367
      typeof textarea.selectionStart !== 'number'
368
    ) {
369
      this.hideAutocomplete();
×
370
      return;
×
371
    }
372

373
    // Use the stored cursor position from when autocomplete was triggered, not the current position
374
    // This is important because the text might have changed since autocomplete was shown
375
    const originalCursorPosition = storedCursorPosition;
×
376

377
    // Parse using the original cursor position to get the original prefix that was typed
378
    const parseResult = parseForAutocomplete(query, originalCursorPosition);
×
379
    const prefixLength = parseResult.prefix.length;
×
380

381
    // Replace the original prefix with the selected item
382
    const beforePrefix = query.slice(0, originalCursorPosition - prefixLength);
×
383
    const afterPrefix = query.slice(originalCursorPosition);
×
384
    const newQuery = beforePrefix + item.text + afterPrefix;
×
385

386
    // Calculate new cursor position (after the inserted text)
387
    const newCursorPosition = beforePrefix.length + item.text.length;
×
388

389
    this.setState(
×
390
      {
391
        query: newQuery,
392
        showAutocomplete: false, // Always hide autocomplete after selection
393
        cursorPosition: newCursorPosition, // Update stored cursor position for next autocomplete
394
        timeout: null, // Clear the timeout from state as well
395
      },
396
      () => {
397
        // Set the cursor position after the state update
398
        setTimeout(() => {
×
399
          // $FlowIssue[method-unbinding]
400
          if (textarea.setSelectionRange) {
×
401
            textarea.setSelectionRange(newCursorPosition, newCursorPosition);
×
402
          }
403

404
          // Trigger parsing immediately with the current state query
405
          this.parseQuery();
×
406

407
          // Clear the autocompleting flag after a delay to prevent immediate re-trigger
408
          setTimeout(() => {
×
409
            this.isAutocompleting = false;
×
410
          }, 300); // Longer delay to ensure all handleChange events are processed
411
        }, 10);
412
      }
413
    );
414
  }
415

416
  hideAutocomplete(): void {
417
    this.setState({
1✔
418
      showAutocomplete: false,
419
      autocompleteItems: [],
420
      selectedIndex: 0,
421
    });
422
  }
423

424
  render(): Node {
425
    // Include any error message if needed
426
    let error: React.Node = <React.Fragment />;
10✔
427
    if (this.state.error) {
10!
428
      error = <div style={{color: 'red'}}>{this.state.error}</div>;
×
429
    }
430

431
    return (
10✔
432
      <div className={'SqlEditor'}>
433
        <label htmlFor="sqlInput">
434
          <h4>SQL Query</h4>
435
        </label>
436
        <div
437
          className="editor"
438
          style={{position: 'relative'}}
439
          onKeyDown={(event) => this.handleKeyDown(event)}
×
440
        >
441
          <Editor
442
            ref={this.editorRef}
443
            value={this.state.query}
444
            // $FlowFixMe[method-unbinding]
445
            onValueChange={this.handleChange}
446
            highlight={(code) => highlight(code, languages.sql)}
2✔
447
            padding={10}
448
            style={{
449
              fontDisplay: 'swap',
450
              fontFamily: '"Fira Code", monospace',
451
            }}
452
            textareaId="sqlInput"
453
          />
454
          {this.state.showAutocomplete && (
10!
455
            <SqlAutocompleteDropdown
456
              items={this.state.autocompleteItems}
457
              selectedIndex={this.state.selectedIndex}
458
              position={this.state.autocompletePosition}
459
              onSelect={(item) => this.handleAutocompleteSelect(item)}
×
460
              onClose={() => this.hideAutocomplete()}
×
461
            />
462
          )}
463
        </div>
464
        <div className="error">{error}</div>
465
      </div>
466
    );
467
  }
468
}
469

470
type SqlEditorWrapperProps = {
471
  navigate: any,
472
  defaultText: string,
473
  exprFromSql: typeof exprFromSql,
474
  resetAction: typeof resetAction,
475
  types: {[string]: Array<string>},
476
  ReactGA?: any, // For backwards compatibility with tests
477
};
478

479
const SqlEditorWithContext: StatelessFunctionalComponent<
480
  SqlEditorWrapperProps,
481
> = (props: SqlEditorWrapperProps) => {
2✔
482
  const contextReactGA = useReactGA();
1✔
483
  const ReactGA = props.ReactGA || contextReactGA;
1✔
484
  return <SqlEditor {...props} ReactGA={ReactGA} />;
1✔
485
};
486

487
export {SqlEditor};
488
export default SqlEditorWithContext;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc