Home Reference Source Test

src/SqlEditor.js

// @flow
import * as React from 'react';
import Editor from 'react-simple-code-editor';
import {highlight, languages} from 'prismjs/components/prism-core';
import 'prismjs/components/prism-sql';
import {exprFromSql} from './modules/relexp';
import {resetAction} from './modules/data';
import SqlAutocompleteDropdown, {
  parseForAutocomplete,
  generateSuggestions,
} from './SqlAutocomplete';
import {useReactGA} from './contexts/ReactGAContext';
import './SqlEditor.css';

import 'prismjs/themes/prism.css';

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

const parser = require('@michaelmior/js-sql-parser');

type Props = {
  navigate: any,
  defaultText: string,
  exprFromSql: typeof exprFromSql,
  resetAction: typeof resetAction,
  ReactGA: any,
  types: {[string]: Array<string>},
};

type State = {
  error: string | null,
  timeout: any,
  query: string,
  showAutocomplete: boolean,
  autocompleteItems: Array<any>,
  selectedIndex: number,
  autocompletePosition: {top: number, left: number},
  cursorPosition: number,
};

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

  constructor() {
    super();
    // $FlowFixMe[method-unbinding]
    (this: any).handleChange = this.handleChange.bind(this);
    // $FlowFixMe[method-unbinding]
    (this: any).parseQuery = this.parseQuery.bind(this);
    // $FlowFixMe[method-unbinding]
    (this: any).handleKeyDown = this.handleKeyDown.bind(this);
    // $FlowFixMe[method-unbinding]
    (this: any).handleAutocompleteSelect =
      // $FlowFixMe[method-unbinding]
      this.handleAutocompleteSelect.bind(this);
    // $FlowFixMe[method-unbinding]
    (this: any).hideAutocomplete = this.hideAutocomplete.bind(this);

    this.editorRef = React.createRef();
    this.isAutocompleting = false;
    this.state = {
      error: null,
      timeout: null,
      query: '',
      showAutocomplete: false,
      autocompleteItems: [],
      selectedIndex: 0,
      autocompletePosition: {top: 0, left: 0},
      cursorPosition: 0,
    };
  }

  componentDidMount() {
    // Parse the initial query when we start
    const values = new URL(window.location.toString()).searchParams;
    const query = values.get('query');

    if (query) {
      this.parseQuery(query, false);
      this.setState({query: query});
    } else {
      this.parseQuery(this.props.defaultText, true);
      this.setState({query: this.props.defaultText});
    }
  }

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

    if (!firstLoad) {
      if (this.props.resetAction) {
        this.props.resetAction();
      }
      this.setState({timeout: null});
    }
    try {
      const sql = parser.parse(queryToParse);
      if (
        sql.nodeType === 'Main' &&
        ['Except', 'Intersect', 'Select', 'Union'].includes(sql.value.type)
      ) {
        // Record the typed SQL statement
        if (!firstLoad && this.props.ReactGA) {
          this.props.ReactGA.event({
            category: 'User Typing SQL Statement',
            action: queryToParse,
          });
        }

        // Parse the query
        this.props.exprFromSql(sql.value, this.props.types);
        this.props.navigate('/?query=' + queryToParse);

        if (!firstLoad) {
          this.setState({error: null});
        }
      } else {
        // Show an error if we try to parse any unsupported query type
        const errMsg = 'Unsupported expression';
        if (!firstLoad) {
          this.setState({error: errMsg});
        }
      }
    } catch (err) {
      // Display any error message generated during parsing
      if (!firstLoad) {
        this.setState({error: err.message});
      }
    }
  }

  handleChange(text: string): void {
    // If we're in the middle of autocomplete, don't trigger parsing or autocomplete - just update the state
    if (this.isAutocompleting) {
      this.setState({query: text});
      return;
    }

    // Cancel any pending query parsing
    if (this.state.timeout) {
      clearTimeout(this.state.timeout);
    }

    // Only parse the query once per second
    let handle = setTimeout(() => {
      // Use current state instead of captured closure variable
      this.parseQuery();
    }, 1000);

    this.setState({timeout: handle, query: text}, () => {
      // Update autocomplete after state is set with a small delay to ensure cursor position is updated
      setTimeout(() => {
        this.updateAutocomplete(text);
      }, 50);
    });
  }

  updateAutocomplete(text: string): void {
    // Check if we're still in autocompleting mode
    if (this.isAutocompleting) {
      return;
    }

    // Get cursor position from the textarea
    const textarea = document.getElementById('sqlInput');
    if (
      !textarea ||
      !(
        textarea instanceof HTMLInputElement ||
        textarea instanceof HTMLTextAreaElement
      ) ||
      typeof textarea.selectionStart !== 'number'
    ) {
      this.hideAutocomplete();
      return;
    }

    const cursorPosition = textarea.selectionStart;
    const parseResult = parseForAutocomplete(text, cursorPosition);

    if (!parseResult.context || !parseResult.prefix) {
      this.hideAutocomplete();
      return;
    }

    const suggestions = generateSuggestions(
      parseResult.prefix,
      parseResult.context,
      this.props.types,
      parseResult.needsTablePrefix
    );

    if (suggestions.length === 0) {
      this.hideAutocomplete();
      return;
    }

    // Scenario 1: Check if the user has typed exactly one of the suggestions
    if (
      suggestions.length === 1 &&
      suggestions[0].text === parseResult.prefix
    ) {
      this.hideAutocomplete();
      return;
    }

    // Also hide if the prefix exactly matches any suggestion (case-insensitive)
    const exactMatch = suggestions.find(
      (suggestion) =>
        suggestion.text.toLowerCase() === parseResult.prefix.toLowerCase()
    );
    if (exactMatch) {
      this.hideAutocomplete();
      return;
    }

    // Calculate position for dropdown using the actual textarea element
    const rect = textarea.getBoundingClientRect();
    const position = this.calculateDropdownPosition(
      textarea,
      cursorPosition,
      rect
    );

    this.setState({
      showAutocomplete: true,
      autocompleteItems: suggestions,
      selectedIndex: 0,
      autocompletePosition: position,
      cursorPosition: cursorPosition, // Update the stored cursor position
    });
  }

  calculateDropdownPosition(
    textarea: any,
    cursorPosition: number,
    rect: ClientRect
  ): {top: number, left: number} {
    // Get text up to cursor position
    const textBeforeCursor = textarea.value.slice(0, cursorPosition);
    const lines = textBeforeCursor.split('\n');
    const currentLine = lines.length - 1;
    const currentLineText = lines[currentLine] || '';

    // Get the computed style of the textarea
    const computedStyle = getComputedStyle(textarea);
    const fontSize = parseFloat(computedStyle.fontSize) || 14;
    const lineHeight = parseFloat(computedStyle.lineHeight) || fontSize * 1.4;

    // Measure actual character width using a temporary element
    let actualCharWidth = fontSize * 0.6; // fallback
    const body = document.body;
    if (body) {
      const measurer = document.createElement('span');
      measurer.style.fontFamily = computedStyle.fontFamily;
      measurer.style.fontSize = computedStyle.fontSize;
      measurer.style.fontWeight = computedStyle.fontWeight;
      measurer.style.letterSpacing = computedStyle.letterSpacing;
      measurer.style.visibility = 'hidden';
      measurer.style.position = 'absolute';
      measurer.style.whiteSpace = 'pre';
      measurer.textContent = currentLineText;

      body.appendChild(measurer);
      const actualTextWidth = measurer.offsetWidth;
      body.removeChild(measurer);

      if (currentLineText.length > 0) {
        actualCharWidth = actualTextWidth / currentLineText.length;
      }
    }

    // Get padding and border values
    const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
    const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
    const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
    const borderLeft = parseFloat(computedStyle.borderLeftWidth) || 0;

    // Calculate the cursor position, then move dropdown below the current line
    const cursorTop =
      rect.top +
      paddingTop +
      borderTop +
      currentLine * lineHeight +
      lineHeight +
      window.scrollY;
    const cursorLeft =
      rect.left +
      paddingLeft +
      borderLeft +
      currentLineText.length * actualCharWidth +
      window.scrollX;

    return {
      top: cursorTop,
      left: cursorLeft,
    };
  }

  handleKeyDown(event: KeyboardEvent): boolean {
    if (!this.state.showAutocomplete) {
      return false;
    }

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        this.setState((prevState) => ({
          selectedIndex:
            (prevState.selectedIndex + 1) % prevState.autocompleteItems.length,
        }));
        return true;

      case 'ArrowUp':
        event.preventDefault();
        this.setState((prevState) => ({
          selectedIndex:
            prevState.selectedIndex === 0
              ? prevState.autocompleteItems.length - 1
              : prevState.selectedIndex - 1,
        }));
        return true;

      case 'Tab':
      case 'Enter':
        event.preventDefault();
        this.handleAutocompleteSelect(
          this.state.autocompleteItems[this.state.selectedIndex]
        );
        return true;

      case 'Escape':
        this.hideAutocomplete();
        return true;

      default:
        return false;
    }
  }

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

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

    // Cancel any pending query parsing from handleChange
    if (this.state.timeout) {
      clearTimeout(this.state.timeout);
    }

    // Get the current cursor position from the actual textarea element
    const textarea = document.getElementById('sqlInput');
    if (
      !textarea ||
      !(
        textarea instanceof HTMLInputElement ||
        textarea instanceof HTMLTextAreaElement
      ) ||
      typeof textarea.selectionStart !== 'number'
    ) {
      this.hideAutocomplete();
      return;
    }

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

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

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

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

    this.setState(
      {
        query: newQuery,
        showAutocomplete: false, // Always hide autocomplete after selection
        cursorPosition: newCursorPosition, // Update stored cursor position for next autocomplete
        timeout: null, // Clear the timeout from state as well
      },
      () => {
        // Set the cursor position after the state update
        setTimeout(() => {
          // $FlowIssue[method-unbinding]
          if (textarea.setSelectionRange) {
            textarea.setSelectionRange(newCursorPosition, newCursorPosition);
          }

          // Trigger parsing immediately with the current state query
          this.parseQuery();

          // Clear the autocompleting flag after a delay to prevent immediate re-trigger
          setTimeout(() => {
            this.isAutocompleting = false;
          }, 300); // Longer delay to ensure all handleChange events are processed
        }, 10);
      }
    );
  }

  hideAutocomplete(): void {
    this.setState({
      showAutocomplete: false,
      autocompleteItems: [],
      selectedIndex: 0,
    });
  }

  render(): Node {
    // Include any error message if needed
    let error: React.Node = <React.Fragment />;
    if (this.state.error) {
      error = <div style={{color: 'red'}}>{this.state.error}</div>;
    }

    return (
      <div className={'SqlEditor'}>
        <label htmlFor="sqlInput">
          <h4>SQL Query</h4>
        </label>
        <div
          className="editor"
          style={{position: 'relative'}}
          onKeyDown={(event) => this.handleKeyDown(event)}
        >
          <Editor
            ref={this.editorRef}
            value={this.state.query}
            // $FlowFixMe[method-unbinding]
            onValueChange={this.handleChange}
            highlight={(code) => highlight(code, languages.sql)}
            padding={10}
            style={{
              fontDisplay: 'swap',
              fontFamily: '"Fira Code", monospace',
            }}
            textareaId="sqlInput"
          />
          {this.state.showAutocomplete && (
            <SqlAutocompleteDropdown
              items={this.state.autocompleteItems}
              selectedIndex={this.state.selectedIndex}
              position={this.state.autocompletePosition}
              onSelect={(item) => this.handleAutocompleteSelect(item)}
              onClose={() => this.hideAutocomplete()}
            />
          )}
        </div>
        <div className="error">{error}</div>
      </div>
    );
  }
}

type SqlEditorWrapperProps = {
  navigate: any,
  defaultText: string,
  exprFromSql: typeof exprFromSql,
  resetAction: typeof resetAction,
  types: {[string]: Array<string>},
  ReactGA?: any, // For backwards compatibility with tests
};

const SqlEditorWithContext: StatelessFunctionalComponent<
  SqlEditorWrapperProps,
> = (props: SqlEditorWrapperProps) => {
  const contextReactGA = useReactGA();
  const ReactGA = props.ReactGA || contextReactGA;
  return <SqlEditor {...props} ReactGA={ReactGA} />;
};

export {SqlEditor};
export default SqlEditorWithContext;