diff --git a/package-lock.json b/package-lock.json index e70bddac..334dbc5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3168,6 +3168,11 @@ } } }, + "code-error-fragment": { + "version": "0.0.230", + "resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz", + "integrity": "sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==" + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -5783,8 +5788,7 @@ "grapheme-splitter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" }, "grid-index": { "version": "1.1.0", @@ -7141,6 +7145,15 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true }, + "json-to-ast": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz", + "integrity": "sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==", + "requires": { + "code-error-fragment": "0.0.230", + "grapheme-splitter": "^1.0.4" + } + }, "json3": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz", diff --git a/package.json b/package.json index de5583d2..ebfb4f0b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "color": "^3.1.2", "detect-browser": "^4.8.0", "file-saver": "^2.0.2", + "json-to-ast": "^2.1.0", "jsonlint": "github:josdejong/jsonlint#85a19d7", "lodash": "^4.17.15", "lodash.capitalize": "^4.2.1", diff --git a/src/components/App.jsx b/src/components/App.jsx index f147a672..839129d4 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -3,6 +3,7 @@ import React from 'react' import cloneDeep from 'lodash.clonedeep' import clamp from 'lodash.clamp' import get from 'lodash.get' +import {unset} from 'lodash' import {arrayMove} from 'react-sortable-hoc' import url from 'url' @@ -97,7 +98,7 @@ export default class App extends React.Component { port = window.location.port } this.styleStore = new ApiStyleStore({ - onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, false), + onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}), port: port, host: params.get("localhost") }) @@ -316,39 +317,87 @@ export default class App extends React.Component { this.onStyleChanged(changedStyle) } - onStyleChanged = (newStyle, save=true) => { + onStyleChanged = (newStyle, opts={}) => { + opts = { + save: true, + addRevision: true, + ...opts, + }; - const errors = validate(newStyle, latest) - if(errors.length === 0) { - - if(newStyle.glyphs !== this.state.mapStyle.glyphs) { - this.updateFonts(newStyle.glyphs) + const errors = validate(newStyle, latest) || []; + const mappedErrors = errors.map(error => { + const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/); + if (layerMatch) { + const [matchStr, index, group, property, message] = layerMatch; + const key = (group && property) ? [group, property].join(".") : property; + return { + message: error.message, + parsed: { + type: "layer", + data: { + index, + key, + message + } + } + } } - if(newStyle.sprite !== this.state.mapStyle.sprite) { - this.updateIcons(newStyle.sprite) + else { + return { + message: error.message, + }; } + }) - this.revisionStore.addRevision(newStyle) - if(save) this.saveStyle(newStyle) - this.setState({ - mapStyle: newStyle, - errors: [], - }) - } else { - this.setState({ - errors: errors.map(err => err.message) - }) + let dirtyMapStyle = undefined; + if (errors.length > 0) { + dirtyMapStyle = cloneDeep(newStyle); + + errors.forEach(error => { + const {message} = error; + if (message) { + try { + const objPath = message.split(":")[0]; + // 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); + } + } + }); } + if(newStyle.glyphs !== this.state.mapStyle.glyphs) { + this.updateFonts(newStyle.glyphs) + } + if(newStyle.sprite !== this.state.mapStyle.sprite) { + this.updateIcons(newStyle.sprite) + } + + if (opts.addRevision) { + this.revisionStore.addRevision(newStyle); + } + if (opts.save) { + this.saveStyle(newStyle); + } + + this.setState({ + mapStyle: newStyle, + dirtyMapStyle: dirtyMapStyle, + errors: mappedErrors, + }) + this.fetchSources(); } onUndo = () => { const activeStyle = this.revisionStore.undo() + const messages = undoMessages(this.state.mapStyle, activeStyle) - this.saveStyle(activeStyle) + this.onStyleChanged(activeStyle, {addRevision: false}); this.setState({ - mapStyle: activeStyle, infos: messages, }) } @@ -356,9 +405,8 @@ export default class App extends React.Component { onRedo = () => { const activeStyle = this.revisionStore.redo() const messages = redoMessages(this.state.mapStyle, activeStyle) - this.saveStyle(activeStyle) + this.onStyleChanged(activeStyle, {addRevision: false}); this.setState({ - mapStyle: activeStyle, infos: messages, }) } @@ -546,10 +594,11 @@ export default class App extends React.Component { } mapRenderer() { + const {mapStyle, dirtyMapStyle} = this.state; const metadata = this.state.mapStyle.metadata || {}; const mapProps = { - mapStyle: this.state.mapStyle, + mapStyle: (dirtyMapStyle || mapStyle), replaceAccessTokens: (mapStyle) => { return style.replaceAccessTokens(mapStyle, { allowFallback: true @@ -663,6 +712,7 @@ export default class App extends React.Component { selectedLayerIndex={this.state.selectedLayerIndex} layers={layers} sources={this.state.sources} + errors={this.state.errors} /> const layerEditor = selectedLayer ? : null const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? : null diff --git a/src/components/MessagePanel.jsx b/src/components/MessagePanel.jsx index 53c873e0..f114a8c4 100644 --- a/src/components/MessagePanel.jsx +++ b/src/components/MessagePanel.jsx @@ -5,11 +5,45 @@ class MessagePanel extends React.Component { static propTypes = { errors: PropTypes.array, infos: PropTypes.array, + mapStyle: PropTypes.object, + onLayerSelect: PropTypes.func, + currentLayer: PropTypes.object, + } + + static defaultProps = { + onLayerSelect: () => {}, } render() { - const errors = this.props.errors.map((m, i) => { - return

{m}

+ const errors = this.props.errors.map((error, idx) => { + let content; + if (error.parsed && error.parsed.type === "layer") { + const {parsed} = error; + const {mapStyle, currentLayer} = this.props; + const layerId = mapStyle.layers[parsed.data.index].id; + content = ( + <> + Layer '{layerId}': {parsed.data.message} + {currentLayer.id !== layerId && + <> +  —  + + + } + + ); + } + else { + content = error.message; + } + return

+ {content} +

}) const infos = this.props.infos.map((m, i) => { diff --git a/src/components/fields/DocLabel.jsx b/src/components/fields/DocLabel.jsx index 83fd6248..5375fbb8 100644 --- a/src/components/fields/DocLabel.jsx +++ b/src/components/fields/DocLabel.jsx @@ -49,8 +49,15 @@ export default class DocLabel extends React.Component { } + else if (label) { + return + } else { - return
+
} } } diff --git a/src/components/fields/FunctionSpecField.jsx b/src/components/fields/FunctionSpecField.jsx index e2542c5a..cc8fb16c 100644 --- a/src/components/fields/FunctionSpecField.jsx +++ b/src/components/fields/FunctionSpecField.jsx @@ -4,14 +4,78 @@ import PropTypes from 'prop-types' import SpecProperty from './_SpecProperty' import DataProperty from './_DataProperty' import ZoomProperty from './_ZoomProperty' +import ExpressionProperty from './_ExpressionProperty' +import {function as styleFunction} from '@mapbox/mapbox-gl-style-spec'; +function isLiteralExpression (value) { + return (Array.isArray(value) && value.length === 2 && value[0] === "literal"); +} + function isZoomField(value) { - return typeof value === 'object' && value.stops && typeof value.property === 'undefined' + return ( + typeof(value) === 'object' && + value.stops && + typeof(value.property) === 'undefined' && + Array.isArray(value.stops) && + value.stops.length > 1 && + value.stops.every(stop => { + return ( + Array.isArray(stop) && + stop.length === 2 + ); + }) + ); } function isDataField(value) { - return typeof value === 'object' && value.stops && typeof value.property !== 'undefined' + return ( + typeof(value) === 'object' && + value.stops && + typeof(value.property) !== 'undefined' && + value.stops.length > 1 && + Array.isArray(value.stops) && + value.stops.every(stop => { + return ( + Array.isArray(stop) && + stop.length === 2 && + typeof(stop[0]) === 'object' + ); + }) + ); +} + +function isPrimative (value) { + const valid = ["string", "boolean", "number"]; + return valid.includes(typeof(value)); +} + +function isArrayOfPrimatives (values) { + if (Array.isArray(values)) { + return values.every(isPrimative); + } + return false; +} + +function getDataType (value, fieldSpec={}) { + if (value === undefined) { + return "value"; + } + else if (isPrimative(value)) { + return "value"; + } + else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) { + return "value"; + } + else if (isZoomField(value)) { + return "zoom_function"; + } + else if (isDataField(value)) { + return "data_function"; + } + else { + return "expression"; + } } /** @@ -40,7 +104,9 @@ export default class FunctionSpecProperty extends React.Component { static propTypes = { onChange: PropTypes.func.isRequired, fieldName: PropTypes.string.isRequired, + fieldType: PropTypes.string.isRequired, fieldSpec: PropTypes.object.isRequired, + errors: PropTypes.object, value: PropTypes.oneOfType([ PropTypes.object, @@ -51,6 +117,27 @@ export default class FunctionSpecProperty extends React.Component { ]), } + constructor (props) { + super(); + this.state = { + dataType: getDataType(props.value, props.fieldSpec), + isEditing: false, + } + } + + static getDerivedStateFromProps(props, state) { + // Because otherwise when editing values we end up accidentally changing field type. + if (state.isEditing) { + return {}; + } + else { + return { + isEditing: false, + dataType: getDataType(props.value, props.fieldSpec) + }; + } + } + getFieldFunctionType(fieldSpec) { if (fieldSpec.expression.interpolated) { return "exponential" @@ -82,6 +169,14 @@ export default class FunctionSpecProperty extends React.Component { this.props.onChange(this.props.fieldName, changedValue) } + deleteExpression = () => { + const {fieldSpec, fieldName} = this.props; + this.props.onChange(fieldName, fieldSpec.default); + this.setState({ + dataType: "value", + }); + } + deleteStop = (stopIdx) => { const stops = this.props.value.stops.slice(0) stops.splice(stopIdx, 1) @@ -108,6 +203,39 @@ export default class FunctionSpecProperty extends React.Component { this.props.onChange(this.props.fieldName, zoomFunc) } + undoExpression = () => { + const {value, fieldName} = this.props; + + if (isLiteralExpression(value)) { + this.props.onChange(fieldName, value[1]); + this.setState({ + dataType: "value", + }); + } + } + + canUndo = () => { + const {value, fieldSpec} = this.props; + return ( + isLiteralExpression(value) || + isPrimative(value) || + (Array.isArray(value) && fieldSpec.type === "array") + ); + } + + makeExpression = () => { + const {value, fieldSpec} = this.props; + let expression; + + if (typeof(value) === "object" && 'stops' in value) { + expression = styleFunction.convertFunction(value, fieldSpec); + } + else { + expression = ["literal", value || this.props.fieldSpec.default]; + } + this.props.onChange(this.props.fieldName, expression); + } + makeDataFunction = () => { const functionType = this.getFieldFunctionType(this.props.fieldSpec); const stopValue = functionType === 'categorical' ? '' : 0; @@ -122,43 +250,78 @@ export default class FunctionSpecProperty extends React.Component { this.props.onChange(this.props.fieldName, dataFunc) } + onMarkEditing = () => { + this.setState({isEditing: true}); + } + + onUnmarkEditing = () => { + this.setState({isEditing: false}); + } + render() { + const {dataType} = this.state; const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property" let specField; - if (isZoomField(this.props.value)) { + if (dataType === "expression") { + specField = ( + + ); + } + else if (dataType === "zoom_function") { specField = ( ) } - else if (isDataField(this.props.value)) { + else if (dataType === "data_function") { specField = ( ) } else { specField = ( ) } diff --git a/src/components/fields/PropertyGroup.jsx b/src/components/fields/PropertyGroup.jsx index 35577244..ae1f4df1 100644 --- a/src/components/fields/PropertyGroup.jsx +++ b/src/components/fields/PropertyGroup.jsx @@ -40,6 +40,7 @@ export default class PropertyGroup extends React.Component { groupFields: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, spec: PropTypes.object.isRequired, + errors: PropTypes.object, } onPropertyChange = (property, newValue) => { @@ -48,18 +49,22 @@ export default class PropertyGroup extends React.Component { } render() { + const {errors} = this.props; const fields = this.props.groupFields.map(fieldName => { const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName) const paint = this.props.layer.paint || {} const layout = this.props.layer.layout || {} const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName] + const fieldType = fieldName in paint ? 'paint' : 'layout'; return }) diff --git a/src/components/fields/_DataProperty.jsx b/src/components/fields/_DataProperty.jsx index b4b5431d..80961b79 100644 --- a/src/components/fields/_DataProperty.jsx +++ b/src/components/fields/_DataProperty.jsx @@ -39,7 +39,9 @@ export default class DataProperty extends React.Component { onChange: PropTypes.func, onDeleteStop: PropTypes.func, onAddStop: PropTypes.func, + onExpressionClick: PropTypes.func, fieldName: PropTypes.string, + fieldType: PropTypes.string, fieldSpec: PropTypes.object, value: PropTypes.oneOfType([ PropTypes.object, @@ -48,6 +50,7 @@ export default class DataProperty extends React.Component { PropTypes.bool, PropTypes.array ]), + errors: PropTypes.object, } state = { @@ -144,6 +147,8 @@ export default class DataProperty extends React.Component { } render() { + const {fieldName, fieldType, errors} = this.props; + if (typeof this.props.value.type === "undefined") { this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec) } @@ -181,7 +186,22 @@ export default class DataProperty extends React.Component {
} - return + const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`; + const foundErrors = Object.entries(errors).filter(([key, error]) => { + return key.startsWith(errorKeyStart); + }); + + const message = foundErrors.map(([key, error]) => { + return error.message; + }).join(""); + const error = message ? {message} : undefined; + + return {zoomInput}
{dataInput} @@ -198,58 +218,64 @@ export default class DataProperty extends React.Component { }) return
-
- -
- -
- this.changeDataProperty("property", propVal)} - /> -
-
-
- -
- this.changeDataProperty("type", propVal)} - title={"Select a type of data scale (default is 'categorical')."} - options={this.getDataFunctionTypes(this.props.fieldSpec)} - /> -
-
-
- -
- this.changeDataProperty("default", propVal)} - /> -
-
- {dataFields} - -
-
+
+ +
+ this.changeDataProperty("property", propVal)} + /> +
+
+
+ +
+ this.changeDataProperty("type", propVal)} + title={"Select a type of data scale (default is 'categorical')."} + options={this.getDataFunctionTypes(this.props.fieldSpec)} + /> +
+
+
+ +
+ this.changeDataProperty("default", propVal)} + /> +
+
+ +
+ {dataFields} + +
} } diff --git a/src/components/fields/_ExpressionProperty.jsx b/src/components/fields/_ExpressionProperty.jsx new file mode 100644 index 00000000..99005991 --- /dev/null +++ b/src/components/fields/_ExpressionProperty.jsx @@ -0,0 +1,135 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import InputBlock from '../inputs/InputBlock' +import Button from '../Button' +import {MdDelete, MdUndo} from 'react-icons/md' +import StringInput from '../inputs/StringInput' + +import labelFromFieldName from './_labelFromFieldName' +import stringifyPretty from 'json-stringify-pretty-compact' +import JSONEditor from '../layers/JSONEditor' + + +export default class ExpressionProperty extends React.Component { + static propTypes = { + onDelete: PropTypes.func, + fieldName: PropTypes.string, + fieldType: PropTypes.string, + fieldSpec: PropTypes.object, + value: PropTypes.any, + errors: PropTypes.object, + onChange: PropTypes.func, + onUndo: PropTypes.func, + canUndo: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + } + + static defaultProps = { + errors: {}, + onFocus: () => {}, + onBlur: () => {}, + } + + constructor (props) { + super(); + this.state = { + jsonError: false, + }; + } + + onJSONInvalid = (err) => { + this.setState({ + jsonError: true, + }) + } + + onJSONValid = () => { + this.setState({ + jsonError: false, + }) + } + + render() { + const {errors, fieldName, fieldType, value, canUndo} = this.props; + const {jsonError} = this.state; + const undoDisabled = canUndo ? !canUndo() : true; + + const deleteStopBtn = ( + <> + {this.props.onUndo && + + } + + + ); + + const fieldKey = fieldType === undefined ? fieldName : `${fieldType}.${fieldName}`; + + 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"}); + } + else { + Object.entries(errors) + .filter(([key, error]) => { + return key.startsWith(errorKeyStart); + }) + .forEach(([key, error]) => { + return foundErrors.push(error); + }) + + if (fieldError) { + foundErrors.push(fieldError); + } + } + + return + + + } +} diff --git a/src/components/fields/_FunctionButtons.jsx b/src/components/fields/_FunctionButtons.jsx index 220e77e8..e96b1351 100644 --- a/src/components/fields/_FunctionButtons.jsx +++ b/src/components/fields/_FunctionButtons.jsx @@ -3,18 +3,50 @@ import PropTypes from 'prop-types' import Button from '../Button' import {MdFunctions, MdInsertChart} from 'react-icons/md' +import {mdiFunctionVariant} from '@mdi/js'; +/** + * So here we can't just check is `Array.isArray(value)` because certain + * properties accept arrays as values, for example `text-font`. So we must try + * and create an expression. + */ +function isExpression(value, fieldSpec={}) { + if (!Array.isArray(value)) { + return false; + } + try { + expression.createExpression(value, fieldSpec); + return true; + } + catch (err) { + return false; + } +} + export default class FunctionButtons extends React.Component { static propTypes = { fieldSpec: PropTypes.object, onZoomClick: PropTypes.func, onDataClick: PropTypes.func, + onExpressionClick: PropTypes.func, } render() { - let makeZoomButton, makeDataButton + let makeZoomButton, makeDataButton, expressionButton; + if (this.props.fieldSpec.expression.parameters.includes('zoom')) { + expressionButton = ( + + ); + makeZoomButton = } - return
{makeDataButton}{makeZoomButton}
+ return
+ {expressionButton} + {makeDataButton} + {makeZoomButton} +
} else { - return null + return
{expressionButton}
} } } diff --git a/src/components/fields/_SpecProperty.jsx b/src/components/fields/_SpecProperty.jsx index febe80a6..7799977e 100644 --- a/src/components/fields/_SpecProperty.jsx +++ b/src/components/fields/_SpecProperty.jsx @@ -13,18 +13,32 @@ export default class SpecProperty extends React.Component { onZoomClick: PropTypes.func.isRequired, onDataClick: PropTypes.func.isRequired, fieldName: PropTypes.string, - fieldSpec: PropTypes.object + fieldType: PropTypes.string, + fieldSpec: PropTypes.object, + value: PropTypes.any, + errors: PropTypes.object, + onExpressionClick: PropTypes.func, + } + + static defaultProps = { + errors: {}, } render() { + const {errors, fieldName, fieldType} = this.props; + const functionBtn = + const error = errors[fieldType+"."+fieldName]; + return { const zoomLevel = stop[0] const key = this.state.refs[idx]; const value = stop[1] const deleteStopBtn= + const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`; + const foundErrors = Object.entries(errors).filter(([key, error]) => { + return key.startsWith(errorKeyStart); + }); + + const message = foundErrors.map(([key, error]) => { + return error.message; + }).join(""); + const error = message ? {message} : undefined; + return Add stop +
} } diff --git a/src/components/fields/_labelFromFieldName.js b/src/components/fields/_labelFromFieldName.js index fea405f6..666d833c 100644 --- a/src/components/fields/_labelFromFieldName.js +++ b/src/components/fields/_labelFromFieldName.js @@ -1,6 +1,13 @@ import capitalize from 'lodash.capitalize' export default function labelFromFieldName(fieldName) { - let label = fieldName.split('-').slice(1).join(' ') - return capitalize(label) + let label; + const parts = fieldName.split('-'); + if (parts.length > 1) { + label = fieldName.split('-').slice(1).join(' '); + } + else { + label = fieldName; + } + return capitalize(label); } diff --git a/src/components/filter/FilterEditor.jsx b/src/components/filter/FilterEditor.jsx index e49948ef..e11125b2 100644 --- a/src/components/filter/FilterEditor.jsx +++ b/src/components/filter/FilterEditor.jsx @@ -2,13 +2,84 @@ import React from 'react' import PropTypes from 'prop-types' import { combiningFilterOps } from '../../libs/filterops.js' -import {latest} from '@mapbox/mapbox-gl-style-spec' +import {latest, validate, migrate} from '@mapbox/mapbox-gl-style-spec' import DocLabel from '../fields/DocLabel' import SelectInput from '../inputs/SelectInput' +import InputBlock from '../inputs/InputBlock' import SingleFilterEditor from './SingleFilterEditor' import FilterEditorBlock from './FilterEditorBlock' import Button from '../Button' import SpecDoc from '../inputs/SpecDoc' +import ExpressionProperty from '../fields/_ExpressionProperty'; +import {mdiFunctionVariant} from '@mdi/js'; + + +function combiningFilter (props) { + let filter = props.filter || ['all']; + + if (!Array.isArray(filter)) { + return filter; + } + + let combiningOp = filter[0]; + let filters = filter.slice(1); + + if(combiningFilterOps.indexOf(combiningOp) < 0) { + combiningOp = 'all'; + filters = [filter.slice(0)]; + } + + return [combiningOp, ...filters]; +} + +function migrateFilter (filter) { + return migrate(createStyleFromFilter(filter)).layers[0].filter; +} + +function createStyleFromFilter (filter) { + return { + "id": "tmp", + "version": 8, + "name": "Empty Style", + "metadata": {"maputnik:renderer": "mbgljs"}, + "sources": { + "tmp": { + "type": "geojson", + "data": {} + } + }, + "sprite": "", + "glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + id: "tmp", + type: "fill", + source: "tmp", + filter: filter, + }, + ], + }; +} + +/** + * This is doing way more work than we need it to, however validating a whole + * style if the only thing that's exported from mapbox-gl-style-spec at the + * moment. Not really an issue though as it take ~0.1ms to calculate. + */ +function checkIfSimpleFilter (filter) { + if (!filter || !combiningFilterOps.includes(filter[0])) { + return false; + } + + // Because "none" isn't supported by the next expression syntax we can test + // with ["none", ...] because it'll return false if it's a new style + // expression. + const moddedFilter = ["none", ...filter.slice(1)]; + const tmpStyle = createStyleFromFilter(moddedFilter) + + const errors = validate(tmpStyle); + return (errors.length < 1); +} function hasCombiningFilter(filter) { return combiningFilterOps.indexOf(filter[0]) >= 0 @@ -27,46 +98,37 @@ export default class CombiningFilterEditor extends React.Component { /** Properties of the vector layer and the available fields */ properties: PropTypes.object, filter: PropTypes.array, + errors: PropTypes.object, onChange: PropTypes.func.isRequired, } - constructor () { + static defaultProps = { + filter: ["all"], + } + + constructor (props) { super(); this.state = { showDoc: false, + displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)), }; } // Convert filter to combining filter - combiningFilter() { - let filter = this.props.filter || ['all'] - - let combiningOp = filter[0] - let filters = filter.slice(1) - - if(combiningFilterOps.indexOf(combiningOp) < 0) { - combiningOp = 'all' - filters = [filter.slice(0)] - } - - return [combiningOp, ...filters] - } - onFilterPartChanged(filterIdx, newPart) { - const newFilter = this.combiningFilter().slice(0) + const newFilter = combiningFilter(this.props).slice(0) newFilter[filterIdx] = newPart this.props.onChange(newFilter) } deleteFilterItem(filterIdx) { - const newFilter = this.combiningFilter().slice(0) - console.log('Delete', filterIdx, newFilter) + const newFilter = combiningFilter(this.props).slice(0) newFilter.splice(filterIdx + 1, 1) this.props.onChange(newFilter) } addFilterItem = () => { - const newFilterItem = this.combiningFilter().slice(0) + const newFilterItem = combiningFilter(this.props).slice(0) newFilterItem.push(['==', 'name', '']) this.props.onChange(newFilterItem) } @@ -77,60 +139,171 @@ export default class CombiningFilterEditor extends React.Component { }); } - render() { - const filter = this.combiningFilter() - let combiningOp = filter[0] - let filters = filter.slice(1) + makeFilter = () => { + this.setState({ + displaySimpleFilter: true, + }) + } + makeExpression = () => { + let filter = combiningFilter(this.props); + this.props.onChange(migrateFilter(filter)); + this.setState({ + displaySimpleFilter: false, + }) + } + + static getDerivedStateFromProps (props, currentState) { + const {filter} = props; + const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props)); + + // Upgrade but never downgrade + if (!displaySimpleFilter && currentState.displaySimpleFilter === true) { + return { + displaySimpleFilter: false, + valueIsSimpleFilter: false, + }; + } + else if (displaySimpleFilter && currentState.displaySimpleFilter === false) { + return { + valueIsSimpleFilter: true, + } + } + else { + return { + valueIsSimpleFilter: false, + }; + } + } + + render() { + const {errors} = this.props; + const {displaySimpleFilter} = this.state; const fieldSpec={ doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter." }; + const defaultFilter = ["all"]; - const editorBlocks = filters.map((f, idx) => { - return - - - }) + const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props)); - //TODO: Implement support for nested filter - if(hasNestedCombiningFilter(filter)) { + if (isNestedCombiningFilter) { return
- Nested filters are not supported. -
- } - - return
-
- - -
- {editorBlocks} -
+

+ Nested filters are not supported. +

-
- -
-
+ } + else if (displaySimpleFilter) { + const filter = combiningFilter(this.props); + let combiningOp = filter[0]; + let filters = filter.slice(1) + + const actions = ( +
+ +
+ ); + + const editorBlocks = filters.map((f, idx) => { + const error = errors[`filter[${idx+1}]`]; + + return ( + <> + + + + {error && +
{error.message}
+ } + + ); + }) + + + return ( + <> + + + + {editorBlocks} +
+ +
+
+ +
+ + ); + } + else { + let {filter} = this.props; + + return ( + <> + { + this.setState({displaySimpleFilter: true}); + this.props.onChange(defaultFilter); + }} + fieldName="filter" + fieldSpec={fieldSpec} + value={filter} + errors={errors} + onChange={this.props.onChange} + /> + {this.state.valueIsSimpleFilter && +
+ You've entered a old style filter,{' '} + +
+ } + + ); + } } } diff --git a/src/components/icons/LayerIcon.jsx b/src/components/icons/LayerIcon.jsx index 7cba8cef..fb5d3277 100644 --- a/src/components/icons/LayerIcon.jsx +++ b/src/components/icons/LayerIcon.jsx @@ -6,6 +6,7 @@ import FillIcon from './FillIcon.jsx' import SymbolIcon from './SymbolIcon.jsx' import BackgroundIcon from './BackgroundIcon.jsx' import CircleIcon from './CircleIcon.jsx' +import MissingIcon from './MissingIcon.jsx' class LayerIcon extends React.Component { static propTypes = { @@ -25,6 +26,7 @@ class LayerIcon extends React.Component { case 'line': return case 'symbol': return case 'circle': return + default: return } } } diff --git a/src/components/icons/MissingIcon.jsx b/src/components/icons/MissingIcon.jsx new file mode 100644 index 00000000..71f76ddd --- /dev/null +++ b/src/components/icons/MissingIcon.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import {MdPriorityHigh} from 'react-icons/md' + + +export default class MissingIcon extends React.Component { + render() { + return ( + + ) + } +} diff --git a/src/components/inputs/InputBlock.jsx b/src/components/inputs/InputBlock.jsx index f35d3b1a..5e1976ed 100644 --- a/src/components/inputs/InputBlock.jsx +++ b/src/components/inputs/InputBlock.jsx @@ -18,6 +18,8 @@ class InputBlock extends React.Component { style: PropTypes.object, onChange: PropTypes.func, fieldSpec: PropTypes.object, + wideMode: PropTypes.bool, + error: PropTypes.array, } constructor (props) { @@ -39,10 +41,13 @@ class InputBlock extends React.Component { } render() { + const errors = [].concat(this.props.error || []); + return
@@ -68,6 +73,13 @@ class InputBlock extends React.Component {
{this.props.children}
+ {errors.length > 0 && +
+ {[].concat(this.props.error).map((error, idx) => { + return
{error.message}
+ })} +
+ } {this.props.fieldSpec &&
also the API has changed, see comment in file -import '../../vendor/codemirror/addon/lint/json-lint' +import stringifyPretty from 'json-stringify-pretty-compact' +import '../util/codemirror-mgl'; class JSONEditor extends React.Component { static propTypes = { - layer: PropTypes.object.isRequired, + layer: PropTypes.any.isRequired, maxHeight: PropTypes.number, onChange: PropTypes.func, + lineNumbers: PropTypes.bool, + lineWrapping: PropTypes.bool, + getValue: PropTypes.func, + gutters: PropTypes.array, + className: PropTypes.string, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + onJSONValid: PropTypes.func, + onJSONInvalid: PropTypes.func, + mode: PropTypes.object, + lint: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + ]), + } + + static defaultProps = { + lineNumbers: true, + lineWrapping: false, + gutters: ["CodeMirror-lint-markers"], + getValue: (data) => { + return stringifyPretty(data, {indent: 2, maxLength: 40}); + }, + onFocus: () => {}, + onBlur: () => {}, + onJSONInvalid: () => {}, + onJSONValid: () => {}, } constructor(props) { super(props) this.state = { isEditing: false, - prevValue: this.getValue(), + prevValue: this.props.getValue(this.props.layer), }; } - getValue () { - return JSON.stringify(this.props.layer, null, 2); - } - componentDidMount () { this._doc = CodeMirror(this._el, { - value: this.getValue(), - mode: { - name: "javascript", - json: true + value: this.props.getValue(this.props.layer), + mode: this.props.mode || { + name: "mgl", }, + lineWrapping: this.props.lineWrapping, tabSize: 2, theme: 'maputnik', viewportMargin: Infinity, - lineNumbers: true, - lint: true, + lineNumbers: this.props.lineNumbers, + lint: this.props.lint || { + context: "layer" + }, matchBrackets: true, - gutters: ["CodeMirror-lint-markers"], + gutters: this.props.gutters, scrollbarStyle: "null", }); @@ -58,12 +83,14 @@ class JSONEditor extends React.Component { } onFocus = () => { + this.props.onFocus(); this.setState({ isEditing: true }); } onBlur = () => { + this.props.onBlur(); this.setState({ isEditing: false }); @@ -79,7 +106,7 @@ class JSONEditor extends React.Component { if (!this.state.isEditing && prevProps.layer !== this.props.layer) { this._cancelNextChange = true; this._doc.setValue( - this.getValue(), + this.props.getValue(this.props.layer), ) } } @@ -87,16 +114,28 @@ class JSONEditor extends React.Component { onChange = (e) => { if (this._cancelNextChange) { this._cancelNextChange = false; + this.setState({ + prevValue: this._doc.getValue(), + }) return; } const newCode = this._doc.getValue(); if (this.state.prevValue !== newCode) { + let parsedLayer, err; try { - const parsedLayer = JSON.parse(newCode) + parsedLayer = JSON.parse(newCode); + } catch(_err) { + err = _err; + console.warn(_err) + } + + if (err) { + this.props.onJSONInvalid(); + } + else { this.props.onChange(parsedLayer) - } catch(err) { - console.warn(err) + this.props.onJSONValid(); } } @@ -112,7 +151,7 @@ class JSONEditor extends React.Component { } return
this._el = el} style={style} /> diff --git a/src/components/layers/LayerEditor.jsx b/src/components/layers/LayerEditor.jsx index ed161163..79b4c161 100644 --- a/src/components/layers/LayerEditor.jsx +++ b/src/components/layers/LayerEditor.jsx @@ -20,6 +20,10 @@ import { changeType, changeProperty } from '../../libs/layer' import layout from '../../config/layout.json' +function getLayoutForType (type) { + return layout[type] ? layout[type] : layout.invalid; +} + function layoutGroups(layerType) { const layerGroup = { title: 'Layer', @@ -33,7 +37,9 @@ function layoutGroups(layerType) { title: 'JSON Editor', type: 'jsoneditor' } - return [layerGroup, filterGroup].concat(layout[layerType].groups).concat([editorGroup]) + return [layerGroup, filterGroup] + .concat(getLayoutForType(layerType).groups) + .concat([editorGroup]) } /** Layer editor supporting multiple types of layers. */ @@ -52,6 +58,7 @@ export default class LayerEditor extends React.Component { isFirstLayer: PropTypes.bool, isLastLayer: PropTypes.bool, layerIndex: PropTypes.number, + errors: PropTypes.array, } static defaultProps = { @@ -79,7 +86,7 @@ export default class LayerEditor extends React.Component { static getDerivedStateFromProps(props, state) { const additionalGroups = { ...state.editorGroups } - layout[props.layer.type].groups.forEach(group => { + getLayoutForType(props.layer.type).groups.forEach(group => { if(!(group.title in additionalGroups)) { additionalGroups[group.title] = true } @@ -118,6 +125,20 @@ export default class LayerEditor extends React.Component { if(this.props.layer.metadata) { comment = this.props.layer.metadata['maputnik:comment'] } + const {errors, layerIndex} = this.props; + + const errorData = {}; + errors.forEach(error => { + if ( + error.parsed && + error.parsed.type === "layer" && + error.parsed.data.index == layerIndex + ) { + errorData[error.parsed.data.key] = { + message: error.parsed.data.message + }; + } + }) let sourceLayerIds; if(this.props.sources.hasOwnProperty(this.props.layer.source)) { @@ -129,13 +150,17 @@ export default class LayerEditor extends React.Component { this.props.onLayerIdChange(this.props.layer.id, newId)} /> this.props.onLayerChanged(changeType(this.props.layer, newType))} /> {this.props.layer.type !== 'background' && this.changeProperty(null, 'source', v)} @@ -143,20 +168,24 @@ export default class LayerEditor extends React.Component { } {['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 && this.changeProperty(null, 'source-layer', v)} /> } this.changeProperty(null, 'minzoom', v)} /> this.changeProperty(null, 'maxzoom', v)} /> this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)} /> @@ -164,6 +193,7 @@ export default class LayerEditor extends React.Component { case 'filter': return
this.changeProperty(null, 'filter', f)} @@ -171,6 +201,7 @@ export default class LayerEditor extends React.Component {
case 'properties': return { const groupIdx = findClosestCommonPrefix(this.props.layers, idx) + const layerError = this.props.errors.find(error => { + return ( + error.parsed && + error.parsed.type === "layer" && + error.parsed.data.index == idx + ); + }); + const listItem = 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex, - 'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1 + 'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1, + 'maputnik-layer-list-item--error': !!layerError })} index={idx} key={layer.id} diff --git a/src/components/layers/LayerTypeBlock.jsx b/src/components/layers/LayerTypeBlock.jsx index 6e29c25c..1c685986 100644 --- a/src/components/layers/LayerTypeBlock.jsx +++ b/src/components/layers/LayerTypeBlock.jsx @@ -4,33 +4,49 @@ import PropTypes from 'prop-types' import {latest} from '@mapbox/mapbox-gl-style-spec' import InputBlock from '../inputs/InputBlock' import SelectInput from '../inputs/SelectInput' +import StringInput from '../inputs/StringInput' class LayerTypeBlock extends React.Component { static propTypes = { value: PropTypes.string.isRequired, wdKey: PropTypes.string, onChange: PropTypes.func.isRequired, + error: PropTypes.object, + disabled: PropTypes.bool, + } + + static defaultProps = { + disabled: false, } render() { return - + {this.props.disabled && + + } + {!this.props.disabled && + + } } } diff --git a/src/components/layers/MaxZoomBlock.jsx b/src/components/layers/MaxZoomBlock.jsx index d05ac76f..ee75f42f 100644 --- a/src/components/layers/MaxZoomBlock.jsx +++ b/src/components/layers/MaxZoomBlock.jsx @@ -9,10 +9,12 @@ class MaxZoomBlock extends React.Component { static propTypes = { value: PropTypes.number, onChange: PropTypes.func.isRequired, + error: PropTypes.object, } render() { return 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; +}); diff --git a/src/config/layout.json b/src/config/layout.json index 7d23808a..7b3321d0 100644 --- a/src/config/layout.json +++ b/src/config/layout.json @@ -233,5 +233,8 @@ ] } ] + }, + "invalid": { + "groups": [] } } diff --git a/src/styles/_components.scss b/src/styles/_components.scss index 8a6ce103..eedefb50 100644 --- a/src/styles/_components.scss +++ b/src/styles/_components.scss @@ -146,7 +146,7 @@ .maputnik-icon-button { background-color: transparent; - &:hover { + &:hover:not(:disabled) { background-color: transparent; label, @@ -182,18 +182,26 @@ .maputnik-action-block { .maputnik-input-block-label { display: inline-block; - width: 35%; + width: 32%; } .maputnik-input-block-action { vertical-align: top; display: inline-block; - width: 15%; + width: 18%; } .maputnik-input-block-action > div { text-align: right; } + +} + +.maputnik-data-spec-block, +.maputnik-zoom-spec-property { + .maputnik-inline-error { + margin-left: 32%; + } } // SPACE HELPER @@ -208,6 +216,12 @@ &-error { color: $color-red; } + + &__switch-button { + all: unset; + text-decoration: underline; + cursor: pointer; + } } .maputnik-dialog { @@ -238,3 +252,51 @@ } } } + +.maputnik-inline-error { + color: #a4a4a4; + padding: 0.4em 0.4em; + font-size: 0.9em; + border: solid 1px $color-red; + border-radius: 2px; + margin: $margin-2 0px; +} + +.maputnik-expression-editor { + border: solid 1px $color-gray; +} + +.maputnik-input-block--wide { + .maputnik-input-block-content { + display: block; + width: auto; + } + + .maputnik-input-block-label { + width: 82%; + } + + .maputnik-input-block-action { + text-align: right; + } +} + +.maputnik-expr-infobox { + font-size: $font-size-6; + background: $color-midgray; + padding: $margin-2; + border-radius: 2px; + border-top-right-radius: 0px; + border-top-left-radius: 0px; + color: $color-white; +} + +.maputnik-expr-infobox__button { + background: none; + border: none; + padding: 0; + text-decoration: underline; + color: currentColor; + cursor: pointer; +} + diff --git a/src/styles/_filtereditor.scss b/src/styles/_filtereditor.scss index 7c3d1542..d836be9e 100644 --- a/src/styles/_filtereditor.scss +++ b/src/styles/_filtereditor.scss @@ -1,5 +1,10 @@ .maputnik-filter-editor-wrapper { padding: $margin-3; + overflow: hidden; + + .maputnik-input-block { + margin: 0; + } } .maputnik-filter-editor { diff --git a/src/styles/_input.scss b/src/styles/_input.scss index 03aa4ea3..b9a48ec7 100644 --- a/src/styles/_input.scss +++ b/src/styles/_input.scss @@ -25,6 +25,11 @@ resize: vertical; height: 78px; } + + &--disabled { + background: transparent; + border: none; + } } .maputnik-number-container { diff --git a/src/styles/_layer.scss b/src/styles/_layer.scss index e933da4f..1648bd44 100644 --- a/src/styles/_layer.scss +++ b/src/styles/_layer.scss @@ -99,6 +99,11 @@ } } + + .maputnik-layer-list-item--error { + color: $color-red; + } + &-item-selected { color: $color-white; } diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index f3db7eb1..b18dfa20 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -38,7 +38,6 @@ &-bottom { position: fixed; - height: 50px; bottom: 0; right: 0; z-index: 1; diff --git a/src/styles/_zoomproperty.scss b/src/styles/_zoomproperty.scss index 2b9ce14b..38e26c55 100644 --- a/src/styles/_zoomproperty.scss +++ b/src/styles/_zoomproperty.scss @@ -2,9 +2,8 @@ .maputnik-make-zoom-function { background-color: transparent; display: inline-block; - padding-bottom: 0; - padding-top: 0; vertical-align: middle; + padding: 0 $margin-2 0 0; @extend .maputnik-icon-button; } @@ -63,9 +62,8 @@ .maputnik-make-data-function { background-color: transparent; display: inline-block; - padding-bottom: 0; - padding-top: 0; vertical-align: middle; + padding: 0 $margin-2 0 0; @extend .maputnik-icon-button; } @@ -98,10 +96,6 @@ .maputnik-data-spec-property-input { width: 75%; display: inline-block; - - .maputnik-string { - margin-bottom: 3%; - } } } } diff --git a/src/styles/index.scss b/src/styles/index.scss index 759b73cf..11cca10a 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -34,3 +34,4 @@ width: 14px; height: 14px; } + diff --git a/src/vendor/codemirror/addon/lint/json-lint.js b/src/vendor/codemirror/addon/lint/json-lint.js deleted file mode 100644 index 5c80a840..00000000 --- a/src/vendor/codemirror/addon/lint/json-lint.js +++ /dev/null @@ -1,31 +0,0 @@ -// CodeMirror, copyright (c) by Marijn Haverbeke and others -// Distributed under an MIT license: http://codemirror.net/LICENSE - -// Depends on fork of jsonlint from -// becuase of -var jsonlint = require("jsonlint"); -var CodeMirror = require("codemirror"); - -CodeMirror.registerHelper("lint", "json", function(text) { - var found = []; - - // NOTE: This was modified from the original to remove the global, also the - // old jsonlint API was 'jsonlint.parseError' its now - // 'jsonlint.parser.parseError' - jsonlint.parser.parseError = function(str, hash) { - var 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 { - jsonlint.parse(text); - } - catch(e) { - // Do nothing we catch the error above - } - return found; -});