diff --git a/src/components/App.jsx b/src/components/App.jsx index 47f57b16..d8d6d16c 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -207,6 +207,7 @@ export default class App extends React.Component { errors: [], infos: [], mapStyle: style.emptyStyle, + hopefulMapStyle: style.emptyStyle, selectedLayerIndex: 0, sources: {}, vectorLayers: {}, @@ -279,7 +280,7 @@ export default class App extends React.Component { } updateFonts(urlTemplate) { - const metadata = this.state.mapStyle.metadata || {} + const metadata = this.state.hopefulMapStyle.metadata || {} const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles let glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate; @@ -318,24 +319,50 @@ export default class App extends React.Component { onStyleChanged = (newStyle, save=true) => { 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 + } + } + } + } + else { + return { + message: error.message, + }; + } + }) + if(errors.length === 0) { - if(newStyle.glyphs !== this.state.mapStyle.glyphs) { + if(newStyle.glyphs !== this.state.hopefulMapStyle.glyphs) { this.updateFonts(newStyle.glyphs) } - if(newStyle.sprite !== this.state.mapStyle.sprite) { + if(newStyle.sprite !== this.state.hopefulMapStyle.sprite) { this.updateIcons(newStyle.sprite) } this.revisionStore.addRevision(newStyle) if(save) this.saveStyle(newStyle) this.setState({ + hopefulMapStyle: newStyle, mapStyle: newStyle, errors: [], }) } else { this.setState({ - errors: errors.map(err => err.message) + hopefulMapStyle: newStyle, + errors: mappedErrors, }) } @@ -344,7 +371,7 @@ export default class App extends React.Component { onUndo = () => { const activeStyle = this.revisionStore.undo() - const messages = undoMessages(this.state.mapStyle, activeStyle) + const messages = undoMessages(this.state.hopefulMapStyle, activeStyle) this.saveStyle(activeStyle) this.setState({ mapStyle: activeStyle, @@ -354,7 +381,7 @@ export default class App extends React.Component { onRedo = () => { const activeStyle = this.revisionStore.redo() - const messages = redoMessages(this.state.mapStyle, activeStyle) + const messages = redoMessages(this.state.hopefulMapStyle, activeStyle) this.saveStyle(activeStyle) this.setState({ mapStyle: activeStyle, @@ -364,7 +391,7 @@ export default class App extends React.Component { onMoveLayer = (move) => { let { oldIndex, newIndex } = move; - let layers = this.state.mapStyle.layers; + let layers = this.state.hopefulMapStyle.layers; oldIndex = clamp(oldIndex, 0, layers.length-1); newIndex = clamp(newIndex, 0, layers.length-1); if(oldIndex === newIndex) return; @@ -382,14 +409,14 @@ export default class App extends React.Component { onLayersChange = (changedLayers) => { const changedStyle = { - ...this.state.mapStyle, + ...this.state.hopefulMapStyle, layers: changedLayers } this.onStyleChanged(changedStyle) } onLayerDestroy = (layerId) => { - let layers = this.state.mapStyle.layers; + let layers = this.state.hopefulMapStyle.layers; const remainingLayers = layers.slice(0); const idx = style.indexOfLayer(remainingLayers, layerId) remainingLayers.splice(idx, 1); @@ -408,7 +435,7 @@ export default class App extends React.Component { } onLayerVisibilityToggle = (layerId) => { - let layers = this.state.mapStyle.layers; + let layers = this.state.hopefulMapStyle.layers; const changedLayers = layers.slice(0) const idx = style.indexOfLayer(changedLayers, layerId) @@ -423,7 +450,7 @@ export default class App extends React.Component { onLayerIdChange = (oldId, newId) => { - const changedLayers = this.state.mapStyle.layers.slice(0) + const changedLayers = this.state.hopefulMapStyle.layers.slice(0) const idx = style.indexOfLayer(changedLayers, oldId) changedLayers[idx] = { @@ -435,7 +462,8 @@ export default class App extends React.Component { } onLayerChanged = (layer) => { - const changedLayers = this.state.mapStyle.layers.slice(0) + console.log("test: onLayerChanged", layer); + const changedLayers = this.state.hopefulMapStyle.layers.slice(0) const idx = style.indexOfLayer(changedLayers, layer.id) changedLayers[idx] = layer @@ -472,7 +500,7 @@ export default class App extends React.Component { fetchSources() { const sourceList = {...this.state.sources}; - for(let [key, val] of Object.entries(this.state.mapStyle.sources)) { + for(let [key, val] of Object.entries(this.state.hopefulMapStyle.sources)) { if(sourceList.hasOwnProperty(key)) { continue; } @@ -596,7 +624,7 @@ export default class App extends React.Component { } onLayerSelect = (layerId) => { - const idx = style.indexOfLayer(this.state.mapStyle.layers, layerId) + const idx = style.indexOfLayer(this.state.hopefulMapStyle.layers, layerId) this.setState({ selectedLayerIndex: idx }) } @@ -636,14 +664,14 @@ export default class App extends React.Component { } render() { - const layers = this.state.mapStyle.layers || [] + const layers = this.state.hopefulMapStyle.layers || [] const selectedLayer = layers.length > 0 ? layers[this.state.selectedLayerIndex] : null - const metadata = this.state.mapStyle.metadata || {} + const metadata = this.state.hopefulMapStyle.metadata || {} const toolbar = const layerEditor = selectedLayer ? : null const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? : null @@ -704,7 +735,7 @@ export default class App extends React.Component { onOpenToggle={this.toggleModal.bind(this, 'shortcuts')} /> { - return

{m}

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

+ {content} +

}) const infos = this.props.infos.map((m, i) => { diff --git a/src/components/fields/FunctionSpecField.jsx b/src/components/fields/FunctionSpecField.jsx index e2542c5a..a6920442 100644 --- a/src/components/fields/FunctionSpecField.jsx +++ b/src/components/fields/FunctionSpecField.jsx @@ -4,6 +4,9 @@ import PropTypes from 'prop-types' import SpecProperty from './_SpecProperty' import DataProperty from './_DataProperty' import ZoomProperty from './_ZoomProperty' +import ExpressionProperty from './_ExpressionProperty' +import {expression} from '@mapbox/mapbox-gl-style-spec' + function isZoomField(value) { @@ -14,6 +17,24 @@ function isDataField(value) { return typeof value === 'object' && value.stops && typeof value.property !== 'undefined' } +/** + * 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 { + const out = expression.createExpression(value, fieldSpec); + return (out.result === "success"); + } + catch (err) { + return false; + } +} + /** * If we don't have a default value just make one up */ @@ -108,6 +129,11 @@ export default class FunctionSpecProperty extends React.Component { this.props.onChange(this.props.fieldName, zoomFunc) } + makeExpression = () => { + const expression = ["literal", this.props.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; @@ -126,9 +152,21 @@ export default class FunctionSpecProperty extends React.Component { const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property" let specField; - if (isZoomField(this.props.value)) { + if (isExpression(this.props.value, this.props.fieldSpec)) { + specField = ( + + ); + } + else if (isZoomField(this.props.value)) { specField = ( ) } diff --git a/src/components/fields/PropertyGroup.jsx b/src/components/fields/PropertyGroup.jsx index 35577244..30515c3e 100644 --- a/src/components/fields/PropertyGroup.jsx +++ b/src/components/fields/PropertyGroup.jsx @@ -48,14 +48,18 @@ 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'; + const errorKey = fieldType+"."+fieldName; return
diff --git a/src/components/fields/_ExpressionProperty.jsx b/src/components/fields/_ExpressionProperty.jsx new file mode 100644 index 00000000..037a040d --- /dev/null +++ b/src/components/fields/_ExpressionProperty.jsx @@ -0,0 +1,87 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import InputBlock from '../inputs/InputBlock' +import Button from '../Button' +import {MdDelete} from 'react-icons/md' +import StringInput from '../inputs/StringInput' + +import labelFromFieldName from './_labelFromFieldName' +import stringifyPretty from 'json-stringify-pretty-compact' + + +function isLiteralExpression (value) { + return (Array.isArray(value) && value.length === 2 && value[0] === "literal"); +} + +export default class ExpressionProperty extends React.Component { + static propTypes = { + onDeleteStop: PropTypes.func, + fieldName: PropTypes.string, + fieldSpec: PropTypes.object + } + + constructor (props) { + super(); + this.state = { + lastValue: props.value, + }; + } + + onChange = (value) => { + try { + const jsonVal = JSON.parse(value); + + if (isLiteralExpression(jsonVal)) { + this.setState({ + lastValue: jsonVal + }); + } + + this.props.onChange(jsonVal); + } + catch (err) { + // TODO: Handle JSON parse error + } + } + + onDelete = () => { + const {lastValue} = this.state; + const {value, fieldName, fieldSpec} = this.props; + + if (isLiteralExpression(value)) { + this.props.onChange(value[1]); + } + else if (isLiteralExpression(lastValue)) { + this.props.onChange(lastValue[1]); + } + else { + this.props.onChange(fieldSpec.default); + } + } + + render() { + const deleteStopBtn = ( + + ); + + return + + + } +} diff --git a/src/components/fields/_FunctionButtons.jsx b/src/components/fields/_FunctionButtons.jsx index 9048a608..0645e62c 100644 --- a/src/components/fields/_FunctionButtons.jsx +++ b/src/components/fields/_FunctionButtons.jsx @@ -6,16 +6,47 @@ import Button from '../Button' import {MdFunctions, MdInsertChart} from 'react-icons/md' +/** + * 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 0e0fd9f7..16a156ed 100644 --- a/src/components/fields/_SpecProperty.jsx +++ b/src/components/fields/_SpecProperty.jsx @@ -21,9 +21,12 @@ export default class SpecProperty extends React.Component { fieldSpec={this.props.fieldSpec} onZoomClick={this.props.onZoomClick} onDataClick={this.props.onDataClick} + value={this.props.value} + onExpressionClick={this.props.onExpressionClick} /> return return { - return - - + const error = errors[`filter[${idx+1}]`]; + + return ( + <> + + + + {error && +
{error.message}
+ } + + ); }) //TODO: Implement support for nested 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 117ba3ee..9cfaacff 100644 --- a/src/components/inputs/InputBlock.jsx +++ b/src/components/inputs/InputBlock.jsx @@ -52,6 +52,11 @@ class InputBlock extends React.Component {
{this.props.children}
+ {this.props.error && +
+ {this.props.error.message} +
+ }
} } diff --git a/src/components/inputs/StringInput.jsx b/src/components/inputs/StringInput.jsx index 87bdd31c..bbb7b227 100644 --- a/src/components/inputs/StringInput.jsx +++ b/src/components/inputs/StringInput.jsx @@ -11,6 +11,7 @@ class StringInput extends React.Component { onInput: PropTypes.func, multi: PropTypes.bool, required: PropTypes.bool, + disabled: PropTypes.bool, } static defaultProps = { @@ -51,9 +52,14 @@ class StringInput extends React.Component { ] } + if(!!this.props.disabled) { + classes.push("maputnik-string--disabled"); + } + return React.createElement(tag, { "data-wd-key": this.props["data-wd-key"], - spellCheck: !(tag === "input"), + spellCheck: this.props.hasOwnProperty("spellCheck") ? this.props.spellCheck : !(tag === "input"), + disabled: this.props.disabled, className: classes.join(" "), style: this.props.style, value: this.state.value === undefined ? "" : this.state.value, diff --git a/src/components/layers/LayerEditor.jsx b/src/components/layers/LayerEditor.jsx index ed161163..538b11a5 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. */ @@ -79,7 +85,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 +124,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 +149,16 @@ 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 +166,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 +191,7 @@ export default class LayerEditor extends React.Component { case 'filter': return
this.changeProperty(null, 'filter', f)} @@ -171,6 +199,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 90884646..9612ecb5 100644 --- a/src/components/layers/LayerTypeBlock.jsx +++ b/src/components/layers/LayerTypeBlock.jsx @@ -4,6 +4,7 @@ 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 = { @@ -15,21 +16,11 @@ class LayerTypeBlock extends React.Component { render() { return - } diff --git a/src/components/layers/MaxZoomBlock.jsx b/src/components/layers/MaxZoomBlock.jsx index 74475935..6e86916c 100644 --- a/src/components/layers/MaxZoomBlock.jsx +++ b/src/components/layers/MaxZoomBlock.jsx @@ -13,6 +13,7 @@ class MaxZoomBlock extends React.Component { render() { return div { diff --git a/src/styles/_input.scss b/src/styles/_input.scss index 6c196a2b..39219e7e 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/_zoomproperty.scss b/src/styles/_zoomproperty.scss index 2b9ce14b..7f45d985 100644 --- a/src/styles/_zoomproperty.scss +++ b/src/styles/_zoomproperty.scss @@ -5,6 +5,7 @@ padding-bottom: 0; padding-top: 0; vertical-align: middle; + padding: 0 $margin-2 0 0; @extend .maputnik-icon-button; } @@ -66,6 +67,7 @@ padding-bottom: 0; padding-top: 0; vertical-align: middle; + padding: 0 $margin-2 0 0; @extend .maputnik-icon-button; } diff --git a/src/styles/index.scss b/src/styles/index.scss index 759b73cf..019857a9 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -34,3 +34,12 @@ width: 14px; height: 14px; } + +.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; +}