From ce976991d4f725ae5b6f3658d057d6c18fa4db76 Mon Sep 17 00:00:00 2001 From: orangemug Date: Sun, 8 Mar 2020 18:38:32 +0000 Subject: [PATCH] Added inline errors to the code-mirror editors based on field spec. --- package.json | 3 +- src/components/App.jsx | 4 +- src/components/fields/_ExpressionProperty.jsx | 11 +- src/components/filter/FilterEditor.jsx | 4 + src/components/layers/JSONEditor.jsx | 17 +- src/components/util/codemirror-mgl.js | 154 ++++++++++++++++++ 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 src/components/util/codemirror-mgl.js diff --git a/package.json b/package.json index de5583d..29aaec4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "color": "^3.1.2", "detect-browser": "^4.8.0", "file-saver": "^2.0.2", - "jsonlint": "github:josdejong/jsonlint#85a19d7", + "json-to-ast": "^2.1.0", + "jsonlint": "^1.6.3", "lodash": "^4.17.15", "lodash.capitalize": "^4.2.1", "lodash.clamp": "^4.0.3", diff --git a/src/components/App.jsx b/src/components/App.jsx index 05e41ae..9463229 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -358,7 +358,9 @@ export default class App extends React.Component { if (message) { try { const objPath = message.split(":")[0]; - unset(dirtyMapStyle, objPath); + // Errors can be deply nested for example 'layers[0].filter[1][1][0]' we only care upto the property 'layers[0].filter' + const unsetPath = objPath.match(/^\S+?\[\d+\]\.[^\[]+/)[0]; + unset(dirtyMapStyle, unsetPath); } catch (err) { console.warn(err); diff --git a/src/components/fields/_ExpressionProperty.jsx b/src/components/fields/_ExpressionProperty.jsx index b50fbb5..9900599 100644 --- a/src/components/fields/_ExpressionProperty.jsx +++ b/src/components/fields/_ExpressionProperty.jsx @@ -83,6 +83,10 @@ export default class ExpressionProperty extends React.Component { const fieldError = errors[fieldKey]; const errorKeyStart = `${fieldKey}[`; const foundErrors = []; + + function getValue (data) { + return stringifyPretty(data, {indent: 2, maxLength: 38}) + } if (jsonError) { foundErrors.push({message: "Invalid JSON"}); @@ -109,6 +113,11 @@ export default class ExpressionProperty extends React.Component { wideMode={true} > stringifyPretty(data, {indent: 2, maxLength: 50})} + getValue={getValue} onChange={this.props.onChange} /> diff --git a/src/components/filter/FilterEditor.jsx b/src/components/filter/FilterEditor.jsx index 5709476..01a0d8c 100644 --- a/src/components/filter/FilterEditor.jsx +++ b/src/components/filter/FilterEditor.jsx @@ -16,6 +16,10 @@ import ExpressionProperty from '../fields/_ExpressionProperty'; function combiningFilter (props) { let filter = props.filter || ['all']; + if (!Array.isArray(filter)) { + return filter; + } + let combiningOp = filter[0]; let filters = filter.slice(1); diff --git a/src/components/layers/JSONEditor.jsx b/src/components/layers/JSONEditor.jsx index 80ce055..05b23f4 100644 --- a/src/components/layers/JSONEditor.jsx +++ b/src/components/layers/JSONEditor.jsx @@ -13,6 +13,7 @@ import 'codemirror/lib/codemirror.css' import 'codemirror/addon/lint/lint.css' import jsonlint from 'jsonlint' import stringifyPretty from 'json-stringify-pretty-compact' +import '../util/codemirror-mgl'; // This is mainly because of this issue also the API has changed, see comment in file import '../../vendor/codemirror/addon/lint/json-lint' @@ -32,6 +33,11 @@ class JSONEditor extends React.Component { onBlur: PropTypes.func, onJSONValid: PropTypes.func, onJSONInvalid: PropTypes.func, + mode: PropTypes.object, + lint: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + ]), } static defaultProps = { @@ -39,7 +45,7 @@ class JSONEditor extends React.Component { lineWrapping: false, gutters: ["CodeMirror-lint-markers"], getValue: (data) => { - return stringifyPretty(data, {indent: 2, maxLength: 50}); + return stringifyPretty(data, {indent: 2, maxLength: 40}); }, onFocus: () => {}, onBlur: () => {}, @@ -58,16 +64,17 @@ class JSONEditor extends React.Component { componentDidMount () { this._doc = CodeMirror(this._el, { value: this.props.getValue(this.props.layer), - mode: { - name: "javascript", - json: true + mode: this.props.mode || { + name: "mgl", }, lineWrapping: this.props.lineWrapping, tabSize: 2, theme: 'maputnik', viewportMargin: Infinity, lineNumbers: this.props.lineNumbers, - lint: true, + lint: this.props.lint || { + context: "layer" + }, matchBrackets: true, gutters: this.props.gutters, scrollbarStyle: "null", diff --git a/src/components/util/codemirror-mgl.js b/src/components/util/codemirror-mgl.js new file mode 100644 index 0000000..f4ab3d4 --- /dev/null +++ b/src/components/util/codemirror-mgl.js @@ -0,0 +1,154 @@ +import jsonlint from 'jsonlint'; +import CodeMirror from 'codemirror'; +import jsonToAst from 'json-to-ast'; +import {expression, validate, latest} from '@mapbox/mapbox-gl-style-spec'; + + +CodeMirror.defineMode("mgl", function(config, parserConfig) { + // Just using the javascript mode with json enabled. Our logic is in the linter below. + return CodeMirror.modes.javascript( + {...config, json: true}, + parserConfig + ); +}); + +CodeMirror.registerHelper("lint", "mgl", function(text, opts, doc) { + const found = []; + const {parser} = jsonlint; + const {context} = opts; + + parser.parseError = function(str, hash) { + const loc = hash.loc; + found.push({ + from: CodeMirror.Pos(loc.first_line - 1, loc.first_column), + to: CodeMirror.Pos(loc.last_line - 1, loc.last_column), + message: str + }); + }; + try { + parser.parse(text); + } + catch (e) {} + + if (found.length > 0) { + // JSON invalid so don't go any further + return found; + } + + const ast = jsonToAst(text); + const input = JSON.parse(text); + + function getArrayPositionalFromAst (node, path) { + if (!node) { + return undefined; + } + else if (path.length < 1) { + return node; + } + else if (!node.children) { + return undefined; + } + else { + const key = path[0]; + let newNode; + if (key.match(/^[0-9]+$/)) { + newNode = node.children[path[0]]; + } + else { + newNode = node.children.find(childNode => { + return ( + childNode.key && + childNode.key.type === "Identifier" && + childNode.key.value === key + ); + }); + if (newNode) { + newNode = newNode.value; + } + } + return getArrayPositionalFromAst(newNode, path.slice(1)) + } + } + + let out; + if (context === "layer") { + // Just an empty style so we can validate a layer. + const errors = validate({ + "version": 8, + "name": "Empty Style", + "metadata": {}, + "sources": {}, + "sprite": "", + "glyphs": "https://example.com/glyphs/{fontstack}/{range}.pbf", + "layers": [ + input + ] + }); + + if (errors) { + out = { + result: "error", + value: errors + .filter(err => { + // Remove missing 'layer source' errors, because we don't include them + if (err.message.match(/^layers\[0\]: source ".*" not found$/)) { + return false; + } + else { + return true; + } + }) + .map(err => { + // Remove the 'layers[0].' as we're validating the layer only here + const errMessageParts = err.message.replace(/^layers\[0\]./, "").split(":"); + return { + key: errMessageParts[0], + message: errMessageParts[1], + }; + }) + } + } + } + else if (context === "expression") { + out = expression.createExpression(input, opts.spec); + } + else { + throw new Error(`Invalid context ${context}`); + } + + if (out.result === "error") { + const errors = out.value; + errors.forEach(error => { + const {key, message} = error; + + if (!key) { + const lastLineHandle = doc.getLineHandle(doc.lastLine()); + const err = { + from: CodeMirror.Pos(doc.firstLine(), 0), + to: CodeMirror.Pos(doc.lastLine(), lastLineHandle.text.length), + message: message, + } + found.push(err); + } + else if (key) { + const path = key.replace(/^\[|\]$/g, "").split(/\.|[\[\]]+/).filter(Boolean) + const parsedError = getArrayPositionalFromAst(ast, path); + if (!parsedError) { + console.warn("Something went wrong parsing error:", error); + return; + } + + const {loc} = parsedError; + const {start, end} = loc; + + found.push({ + from: CodeMirror.Pos(start.line - 1, start.column), + to: CodeMirror.Pos(end.line - 1, end.column), + message: message, + }); + } + }) + } + + return found; +});