From 6701b0b626e43800e32413590a295e5c1e3d5419 Mon Sep 17 00:00:00 2001 From: Lee Byron Date: Mon, 7 Mar 2016 20:06:11 -0800 Subject: [PATCH] [RFC] Variable hint/lint This uses codemirror-graphql v0.3.0 which adds support for a variables JSON mode which provides more specific syntax highlighting, as well as richer typeaheads and error detection. --- package.json | 2 +- src/codemirror/__tests__/json-lint-test.js | 63 ----- src/codemirror/lint/json-lint.js | 36 --- src/codemirror/lint/jsonLint.js | 242 ------------------ src/components/GraphiQL.js | 49 +++- src/components/QueryEditor.js | 98 +------ src/components/VariableEditor.js | 58 ++++- .../__tests__/collectVariables-test.js | 66 +++++ src/utility/collectVariables.js | 31 +++ src/utility/debounce.js | 22 ++ src/utility/onHasCompletion.js | 111 ++++++++ 11 files changed, 331 insertions(+), 447 deletions(-) delete mode 100644 src/codemirror/__tests__/json-lint-test.js delete mode 100644 src/codemirror/lint/json-lint.js delete mode 100644 src/codemirror/lint/jsonLint.js create mode 100644 src/utility/__tests__/collectVariables-test.js create mode 100644 src/utility/collectVariables.js create mode 100644 src/utility/debounce.js create mode 100644 src/utility/onHasCompletion.js diff --git a/package.json b/package.json index 8effbc53785..feba1a921c5 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "codemirror": "^5.6.0", - "codemirror-graphql": "^0.2.2", + "codemirror-graphql": "^0.3.0", "marked": "^0.3.5" }, "peerDependencies": { diff --git a/src/codemirror/__tests__/json-lint-test.js b/src/codemirror/__tests__/json-lint-test.js deleted file mode 100644 index c4b5b2bb083..00000000000 --- a/src/codemirror/__tests__/json-lint-test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) 2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the license found in the - * LICENSE-examples file in the root directory of this source tree. - */ - -import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import CodeMirror from 'codemirror'; -import 'codemirror/mode/javascript/javascript'; -import 'codemirror/addon/lint/lint'; -import '../lint/json-lint'; - -/* eslint-disable max-len */ - -function createEditorWithLint() { - return CodeMirror(document.createElement('div'), { - mode: { - name: 'javascript', - json: true - }, - lint: true - }); -} - -function printLintErrors(queryString) { - var editor = createEditorWithLint(); - - return new Promise((resolve, reject) => { - editor.state.lint.options.onUpdateLinting = (errors) => { - if (errors && errors[0]) { - if (!errors[0].message.match('Unexpected EOF')) { - resolve(errors); - } - } - reject(); - }; - editor.doc.setValue(queryString); - }).then((errors) => { - return errors; - }).catch(() => { - return []; - }); -} - -describe('graphql-lint', () => { - - it('attaches a GraphQL lint function with correct mode/lint options', () => { - var editor = createEditorWithLint(); - expect( - editor.getHelpers(editor.getCursor(), 'lint') - ).to.not.have.lengthOf(0); - }); - - it('catches syntax errors', async () => { - expect( - (await printLintErrors(`x`))[0].message - ).to.contain('Expected { but got x.'); - }); - -}); diff --git a/src/codemirror/lint/json-lint.js b/src/codemirror/lint/json-lint.js deleted file mode 100644 index 2632a3128ab..00000000000 --- a/src/codemirror/lint/json-lint.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the license found in the - * LICENSE-examples file in the root directory of this source tree. - */ - -import CodeMirror from 'codemirror'; -import { jsonLint } from './jsonLint'; - - -CodeMirror.registerHelper('lint', 'json', text => { - var err = jsonLint(text); - if (err) { - return [ { - message: err.message, - severity: 'error', - from: getLocation(text, err.start), - to: getLocation(text, err.end) - } ]; - } - return []; -}); - -function getLocation(source, position) { - var line = 0; - var column = position; - var lineRegexp = /\r\n|[\n\r]/g; - var match; - while ((match = lineRegexp.exec(source)) && match.index < position) { - line += 1; - column = position - (match.index + match[0].length); - } - return CodeMirror.Pos(line, column); -} diff --git a/src/codemirror/lint/jsonLint.js b/src/codemirror/lint/jsonLint.js deleted file mode 100644 index ba1b8f6fce3..00000000000 --- a/src/codemirror/lint/jsonLint.js +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Copyright (c) 2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the license found in the - * LICENSE-examples file in the root directory of this source tree. - */ - - -/** - * This JSON parser simply walks the input, but does not generate an AST - * or Value. Instead it returns either an syntax error object, or null. - * - * The returned syntax error object: - * - * - message: string - * - start: int - the start inclusive offset of the syntax error - * - end: int - the end exclusive offset of the syntax error - * - */ -export function jsonLint(str, looseMode) { - string = str; - strLen = str.length; - end = -1; - try { - ch(); - lex(); - if (looseMode) { - readVal(); - } else { - readObj(); - } - expect('EOF'); - } catch (err) { - return err; - } -} - -var string; -var strLen; -var start; -var end; -var code; -var kind; - -function readObj() { - expect('{'); - if (!skip('}')) { - do { - expect('String'); - expect(':'); - readVal(); - } while (skip(',')); - expect('}'); - } -} - -function readArr() { - expect('['); - if (!skip(']')) { - do { - readVal(); - } while (skip(',')); - expect(']'); - } -} - -function readVal() { - switch (kind) { - case '[': return readArr(); - case '{': return readObj(); - case 'String': return lex(); - default: return expect('Value'); - } -} - -function syntaxError(message) { - return { message, start, end }; -} - -function expect(str) { - if (kind === str) { - return lex(); - } - throw syntaxError(`Expected ${str} but got ${string.slice(start, end)}.`); -} - -function skip(k) { - if (kind === k) { - lex(); - return true; - } -} - -function ch() { - if (end < strLen) { - end++; - code = end === strLen ? 0 : string.charCodeAt(end); - } -} - -function lex() { - while (code === 9 || code === 10 || code === 13 || code === 32) { - ch(); - } - - if (code === 0) { - kind = 'EOF'; - return; - } - - start = end; - - switch (code) { - // " - case 34: - kind = 'String'; - return readString(); - // - - case 45: - // 0-9 - case 48: case 49: case 50: case 51: case 52: - case 53: case 54: case 55: case 56: case 57: - kind = 'Value'; - return readNumber(); - // f - case 102: - if (string.slice(start, start + 5) !== 'false') { - break; - } - end += 4; ch(); - - kind = 'Value'; - return; - // n - case 110: - if (string.slice(start, start + 4) !== 'null') { - break; - } - end += 3; ch(); - - kind = 'Value'; - return; - // t - case 116: - if (string.slice(start, start + 4) !== 'true') { - break; - } - end += 3; ch(); - - kind = 'Value'; - return; - } - - kind = string[start]; - ch(); -} - -function readString() { - ch(); - while (code !== 34) { - ch(); - if (code === 92) { // \ - ch(); - switch (code) { - case 34: // ' - case 47: // / - case 92: // \ - case 98: // b - case 102: // f - case 110: // n - case 114: // r - case 116: // t - ch(); - break; - case 117: // u - ch(); - readHex(); - readHex(); - readHex(); - readHex(); - break; - default: - throw syntaxError('Bad character escape sequence.'); - } - } else if (end === strLen) { - throw syntaxError('Unterminated string.'); - } - } - - if (code === 34) { - ch(); - return; - } - - throw syntaxError('Unterminated string.'); -} - -function readHex() { - if ( - (code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 70) || // A-F - (code >= 97 && code <= 102) // a-f - ) { - return ch(); - } - throw syntaxError('Expected hexadecimal digit.'); -} - -function readNumber() { - if (code === 45) { // - - ch(); - } - - if (code === 48) { // 0 - ch(); - } else { - readDigits(); - } - - if (code === 46) { // . - ch(); - readDigits(); - } - - if (code === 69 || code === 101) { // E e - ch(); - if (code === 43 || code === 45) { // + - - ch(); - } - readDigits(); - } -} - -function readDigits() { - if (code < 48 || code > 57) { // 0 - 9 - throw syntaxError('Expected decimal digit.'); - } - do { - ch(); - } while (code >= 48 && code <= 57); // 0 - 9 -} diff --git a/src/components/GraphiQL.js b/src/components/GraphiQL.js index a44e4d453d0..e95b63e5004 100644 --- a/src/components/GraphiQL.js +++ b/src/components/GraphiQL.js @@ -21,9 +21,11 @@ import { QueryEditor } from './QueryEditor'; import { VariableEditor } from './VariableEditor'; import { ResultViewer } from './ResultViewer'; import { DocExplorer } from './DocExplorer'; -import { getLeft, getTop } from '../utility/elementPosition'; -import { fillLeafs } from '../utility/fillLeafs'; +import collectVariables from '../utility/collectVariables'; +import debounce from '../utility/debounce'; import find from '../utility/find'; +import { fillLeafs } from '../utility/fillLeafs'; +import { getLeft, getTop } from '../utility/elementPosition'; import { introspectionQuery, introspectionQuerySansSubscriptions, @@ -174,11 +176,15 @@ export class GraphiQL extends React.Component { props.variables !== undefined ? props.variables : this._storageGet('variables'); + // Get the initial valid variables. + let variableToType = getVariableToType(props.schema, query); + // Initialize state this.state = { schema: props.schema, query, variables, + variableToType, response: props.response, editorFlex: this._storageGet('editorFlex') || 1, variableEditorOpen: Boolean(variables), @@ -196,6 +202,7 @@ export class GraphiQL extends React.Component { let nextSchema = this.state.schema; let nextQuery = this.state.query; let nextVariables = this.state.variables; + let nextVariableToType = this.state.variableToType; let nextResponse = this.state.response; if (nextProps.schema !== undefined) { nextSchema = nextProps.schema; @@ -206,6 +213,13 @@ export class GraphiQL extends React.Component { if (nextProps.variables !== undefined) { nextVariables = nextProps.variables; } + if (nextSchema && nextQuery && + (nextSchema !== this.state.schema || nextQuery !== this.state.query)) { + const newVariableToType = getVariableToType(nextSchema, nextQuery); + if (newVariableToType) { + nextVariableToType = newVariableToType; + } + } if (nextProps.response !== undefined) { nextResponse = nextProps.response; } @@ -213,6 +227,7 @@ export class GraphiQL extends React.Component { schema: nextSchema, query: nextQuery, variables: nextVariables, + variableToType: nextVariableToType, response: nextResponse, }); } @@ -238,7 +253,12 @@ export class GraphiQL extends React.Component { } if (result.data) { - this.setState({ schema: buildClientSchema(result.data) }); + const schema = buildClientSchema(result.data); + const newVariableToType = getVariableToType(schema, this.state.query); + this.setState({ + schema, + variableToType: newVariableToType || this.state.variableToType + }); } else { let responseString = typeof result === 'string' ? result : @@ -332,7 +352,9 @@ export class GraphiQL extends React.Component { @@ -412,6 +434,9 @@ export class GraphiQL extends React.Component { } _onEditQuery = value => { + if (this.state.schema) { + this._updateVariableToType(value); + } this._storageSet('query', value); this.setState({ query: value }); if (this.props.onEditQuery) { @@ -419,6 +444,13 @@ export class GraphiQL extends React.Component { } } + _updateVariableToType = debounce(500, value => { + const newVariableToType = getVariableToType(this.state.schema, value); + if (newVariableToType) { + this.setState({ variableToType: newVariableToType }); + } + }) + _onEditVariables = value => { this._storageSet('variables', value); this.setState({ variables: value }); @@ -656,3 +688,14 @@ const defaultQuery = # will appear in the pane to the right. `; + +// Returns a `variableToType` mapping, or null not possible. +function getVariableToType(schema, query) { + if (schema && query) { + try { + return collectVariables(schema, parse(query)); + } catch (e) { + // No op. + } + } +} diff --git a/src/components/QueryEditor.js b/src/components/QueryEditor.js index ec058243132..a38976e5697 100644 --- a/src/components/QueryEditor.js +++ b/src/components/QueryEditor.js @@ -8,9 +8,8 @@ import React, { PropTypes } from 'react'; import ReactDOM from 'react-dom'; -import marked from 'marked'; import CodeMirror from 'codemirror'; -import { GraphQLSchema, GraphQLNonNull, GraphQLList } from 'graphql'; +import { GraphQLSchema } from 'graphql'; import 'codemirror/addon/hint/show-hint'; import 'codemirror/addon/comment/comment'; import 'codemirror/addon/edit/matchbrackets'; @@ -23,6 +22,8 @@ import 'codemirror-graphql/hint'; import 'codemirror-graphql/lint'; import 'codemirror-graphql/mode'; +import onHasCompletion from '../utility/onHasCompletion'; + /** * QueryEditor @@ -154,101 +155,10 @@ export class QueryEditor extends React.Component { * about the type and description for the selected context. */ _onHasCompletion = (cm, data) => { - var wrapper; - var information; - - // When a hint result is selected, we touch the UI. - CodeMirror.on(data, 'select', (ctx, el) => { - // Only the first time (usually when the hint UI is first displayed) - // do we create the wrapping node. - if (!wrapper) { - // Wrap the existing hint UI, so we have a place to put information. - var hintsUl = el.parentNode; - var container = hintsUl.parentNode; - wrapper = document.createElement('div'); - container.appendChild(wrapper); - - // CodeMirror vertically inverts the hint UI if there is not enough - // space below the cursor. Since this modified UI appends to the bottom - // of CodeMirror's existing UI, it could cover the cursor. This adjusts - // the positioning of the hint UI to accomodate. - var top = hintsUl.style.top; - var bottom = ''; - var cursorTop = cm.cursorCoords().top; - if (parseInt(top, 10) < cursorTop) { - top = ''; - bottom = (window.innerHeight - cursorTop + 3) + 'px'; - } - - // Style the wrapper, remove positioning from hints. Note that usage - // of this option will need to specify CSS to remove some styles from - // the existing hint UI. - wrapper.className = 'CodeMirror-hints-wrapper'; - wrapper.style.left = hintsUl.style.left; - wrapper.style.top = top; - wrapper.style.bottom = bottom; - hintsUl.style.left = ''; - hintsUl.style.top = ''; - - // This "information" node will contain the additional info about the - // highlighted typeahead option. - information = document.createElement('div'); - information.className = 'CodeMirror-hint-information'; - if (bottom) { - wrapper.appendChild(information); - wrapper.appendChild(hintsUl); - } else { - wrapper.appendChild(hintsUl); - wrapper.appendChild(information); - } - - // When CodeMirror attempts to remove the hint UI, we detect that it was - // removed from our wrapper and in turn remove the wrapper from the - // original container. - var onRemoveFn; - wrapper.addEventListener('DOMNodeRemoved', onRemoveFn = event => { - if (event.target === hintsUl) { - wrapper.removeEventListener('DOMNodeRemoved', onRemoveFn); - wrapper.parentNode.removeChild(wrapper); - wrapper = null; - information = null; - onRemoveFn = null; - } - }); - } - - // Now that the UI has been set up, add info to information. - var description = ctx.description ? - marked(ctx.description, { smartypants: true }) : - 'Self descriptive.'; - var type = ctx.type ? - '' + renderType(ctx.type) + '' : - ''; - - information.innerHTML = '
' + - (description.slice(0, 3) === '

' ? - '

' + type + description.slice(3) : - type + description) + '

'; - - // Additional rendering? - var onHintInformationRender = this.props.onHintInformationRender; - if (onHintInformationRender) { - onHintInformationRender(information); - } - }); + onHasCompletion(cm, data, this.props.onHintInformationRender); } render() { return
; } } - -function renderType(type) { - if (type instanceof GraphQLNonNull) { - return `${renderType(type.ofType)}!`; - } - if (type instanceof GraphQLList) { - return `[${renderType(type.ofType)}]`; - } - return `${type.name}`; -} diff --git a/src/components/VariableEditor.js b/src/components/VariableEditor.js index 50a494269ae..6fff63acdeb 100644 --- a/src/components/VariableEditor.js +++ b/src/components/VariableEditor.js @@ -9,12 +9,18 @@ import React, { PropTypes } from 'react'; import ReactDOM from 'react-dom'; import CodeMirror from 'codemirror'; +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/edit/matchbrackets'; +import 'codemirror/addon/edit/closebrackets'; import 'codemirror/addon/fold/brace-fold'; import 'codemirror/addon/fold/foldgutter'; import 'codemirror/addon/lint/lint'; import 'codemirror/keymap/sublime'; -import 'codemirror/mode/javascript/javascript'; -import '../codemirror/lint/json-lint'; +import 'codemirror-graphql/variables/hint'; +import 'codemirror-graphql/variables/lint'; +import 'codemirror-graphql/variables/mode'; + +import onHasCompletion from '../utility/onHasCompletion'; /** @@ -24,12 +30,14 @@ import '../codemirror/lint/json-lint'; * * Props: * + * - variableToType: A mapping of variable name to GraphQLType. * - value: The text of the editor. * - onEdit: A function called when the editor changes, given the edited text. * */ export class VariableEditor extends React.Component { static propTypes = { + variableToType: PropTypes.object, value: PropTypes.string, onEdit: PropTypes.func } @@ -47,21 +55,29 @@ export class VariableEditor extends React.Component { this.editor = CodeMirror(ReactDOM.findDOMNode(this), { value: this.props.value || '', lineNumbers: true, + tabSize: 2, + mode: 'graphql-variables', theme: 'graphiql', - mode: { - name: 'javascript', - json: true - }, - lint: true, + keyMap: 'sublime', autoCloseBrackets: true, matchBrackets: true, showCursorWhenSelecting: true, - keyMap: 'sublime', foldGutter: { minFoldSize: 4 }, + lint: { + variableToType: this.props.variableToType + }, + hintOptions: { + variableToType: this.props.variableToType + }, gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-foldgutter' ], extraKeys: { + 'Cmd-Space': () => this.editor.showHint({ completeSingle: false }), + 'Ctrl-Space': () => this.editor.showHint({ completeSingle: false }), + 'Alt-Space': () => this.editor.showHint({ completeSingle: false }), + 'Shift-Space': () => this.editor.showHint({ completeSingle: false }), + // Editor improvements 'Ctrl-Left': 'goSubwordLeft', 'Ctrl-Right': 'goSubwordRight', @@ -71,10 +87,14 @@ export class VariableEditor extends React.Component { }); this.editor.on('change', this._onEdit); + this.editor.on('keyup', this._onKeyUp); + this.editor.on('hasCompletion', this._onHasCompletion); } componentWillUnmount() { this.editor.off('change', this._onEdit); + this.editor.off('keyup', this._onKeyUp); + this.editor.off('hasCompletion', this._onHasCompletion); this.editor = null; } @@ -83,6 +103,12 @@ export class VariableEditor extends React.Component { // user-input changes which could otherwise result in an infinite // event loop. this.ignoreChangeEvent = true; + if (this.props.variableToType !== prevProps.variableToType) { + this.editor.options.lint.variableToType = this.props.variableToType; + this.editor.options.hintOptions.variableToType = + this.props.variableToType; + CodeMirror.signal(this.editor, 'change', this.editor); + } if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) { this.cachedValue = this.props.value; @@ -91,6 +117,18 @@ export class VariableEditor extends React.Component { this.ignoreChangeEvent = false; } + _onKeyUp = (cm, event) => { + var code = event.keyCode; + if ( + (code >= 65 && code <= 90) || // letters + (!event.shiftKey && code >= 48 && code <= 57) || // numbers + (event.shiftKey && code === 189) || // underscore + (event.shiftKey && code === 222) // " + ) { + this.editor.execCommand('autocomplete'); + } + } + _onEdit = () => { if (!this.ignoreChangeEvent) { this.cachedValue = this.editor.getValue(); @@ -100,6 +138,10 @@ export class VariableEditor extends React.Component { } } + _onHasCompletion = (cm, data) => { + onHasCompletion(cm, data, this.props.onHintInformationRender); + } + render() { return
; } diff --git a/src/utility/__tests__/collectVariables-test.js b/src/utility/__tests__/collectVariables-test.js new file mode 100644 index 00000000000..fe2a7403f30 --- /dev/null +++ b/src/utility/__tests__/collectVariables-test.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse, +} from 'graphql'; + +import collectVariables from '../collectVariables'; + + +describe('collectVariables', () => { + + const TestType = new GraphQLObjectType({ + name: 'Test', + fields: { + id: { type: GraphQLID }, + string: { type: GraphQLString }, + int: { type: GraphQLInt }, + float: { type: GraphQLFloat }, + boolean: { type: GraphQLBoolean }, + } + }); + + const TestSchema = new GraphQLSchema({ + query: TestType + }); + + it('returns an empty object if no variables exist', () => { + const variableToType = collectVariables(TestSchema, parse(`{ id }`)); + expect(variableToType).to.deep.equal({}); + }); + + it('collects variable types from a schema and query', () => { + const variableToType = collectVariables(TestSchema, parse(` + query ($foo: Int, $bar: String) { id } + `)); + expect(Object.keys(variableToType)).to.deep.equal([ 'foo', 'bar' ]); + expect(variableToType.foo).to.equal(GraphQLInt); + expect(variableToType.bar).to.equal(GraphQLString); + }); + + it('collects variable types from multiple queries', () => { + const variableToType = collectVariables(TestSchema, parse(` + query A($foo: Int, $bar: String) { id } + query B($foo: Int, $baz: Float) { id } + `)); + expect(Object.keys(variableToType)).to.deep.equal([ 'foo', 'bar', 'baz' ]); + expect(variableToType.foo).to.equal(GraphQLInt); + expect(variableToType.bar).to.equal(GraphQLString); + expect(variableToType.baz).to.equal(GraphQLFloat); + }); +}); diff --git a/src/utility/collectVariables.js b/src/utility/collectVariables.js new file mode 100644 index 00000000000..97b237651ff --- /dev/null +++ b/src/utility/collectVariables.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +import { typeFromAST } from 'graphql'; + + +/** + * Provided a schema and a document, produces a `variableToType` Object. + */ +export default function collectVariables(schema, documentAST) { + const variableToType = Object.create(null); + documentAST.definitions.forEach(definition => { + if (definition.kind === 'OperationDefinition') { + const variableDefinitions = definition.variableDefinitions; + if (variableDefinitions) { + variableDefinitions.forEach(({ variable, type }) => { + const inputType = typeFromAST(schema, type); + if (inputType) { + variableToType[variable.name.value] = inputType; + } + }); + } + } + }); + return variableToType; +} diff --git a/src/utility/debounce.js b/src/utility/debounce.js new file mode 100644 index 00000000000..314e468e194 --- /dev/null +++ b/src/utility/debounce.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE-examples file in the root directory of this source tree. + */ + +/** + * Provided a duration and a function, returns a new function which is called + * `duration` milliseconds after the last call. + */ +export default function debounce(duration, fn) { + let timeout; + return function () { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + fn.apply(this, arguments); + }, duration); + }; +} diff --git a/src/utility/onHasCompletion.js b/src/utility/onHasCompletion.js new file mode 100644 index 00000000000..64ff4a66b7f --- /dev/null +++ b/src/utility/onHasCompletion.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import CodeMirror from 'codemirror'; +import { GraphQLNonNull, GraphQLList } from 'graphql'; +import marked from 'marked'; + + +/** + * Render a custom UI for CodeMirror's hint which includes additional info + * about the type and description for the selected context. + */ +export default function onHasCompletion(cm, data, onHintInformationRender) { + let wrapper; + let information; + + // When a hint result is selected, we touch the UI. + CodeMirror.on(data, 'select', (ctx, el) => { + // Only the first time (usually when the hint UI is first displayed) + // do we create the wrapping node. + if (!wrapper) { + // Wrap the existing hint UI, so we have a place to put information. + const hintsUl = el.parentNode; + const container = hintsUl.parentNode; + wrapper = document.createElement('div'); + container.appendChild(wrapper); + + // CodeMirror vertically inverts the hint UI if there is not enough + // space below the cursor. Since this modified UI appends to the bottom + // of CodeMirror's existing UI, it could cover the cursor. This adjusts + // the positioning of the hint UI to accomodate. + let top = hintsUl.style.top; + let bottom = ''; + const cursorTop = cm.cursorCoords().top; + if (parseInt(top, 10) < cursorTop) { + top = ''; + bottom = (window.innerHeight - cursorTop + 3) + 'px'; + } + + // Style the wrapper, remove positioning from hints. Note that usage + // of this option will need to specify CSS to remove some styles from + // the existing hint UI. + wrapper.className = 'CodeMirror-hints-wrapper'; + wrapper.style.left = hintsUl.style.left; + wrapper.style.top = top; + wrapper.style.bottom = bottom; + hintsUl.style.left = ''; + hintsUl.style.top = ''; + + // This "information" node will contain the additional info about the + // highlighted typeahead option. + information = document.createElement('div'); + information.className = 'CodeMirror-hint-information'; + if (bottom) { + wrapper.appendChild(information); + wrapper.appendChild(hintsUl); + } else { + wrapper.appendChild(hintsUl); + wrapper.appendChild(information); + } + + // When CodeMirror attempts to remove the hint UI, we detect that it was + // removed from our wrapper and in turn remove the wrapper from the + // original container. + let onRemoveFn; + wrapper.addEventListener('DOMNodeRemoved', onRemoveFn = event => { + if (event.target === hintsUl) { + wrapper.removeEventListener('DOMNodeRemoved', onRemoveFn); + wrapper.parentNode.removeChild(wrapper); + wrapper = null; + information = null; + onRemoveFn = null; + } + }); + } + + // Now that the UI has been set up, add info to information. + const description = ctx.description ? + marked(ctx.description, { smartypants: true }) : + 'Self descriptive.'; + const type = ctx.type ? + '' + renderType(ctx.type) + '' : + ''; + + information.innerHTML = '
' + + (description.slice(0, 3) === '

' ? + '

' + type + description.slice(3) : + type + description) + '

'; + + // Additional rendering? + if (onHintInformationRender) { + onHintInformationRender(information); + } + }); +} + +function renderType(type) { + if (type instanceof GraphQLNonNull) { + return `${renderType(type.ofType)}!`; + } + if (type instanceof GraphQLList) { + return `[${renderType(type.ofType)}]`; + } + return `${type.name}`; +}