Merge pull request #620 from orangemug/feature/ui-errors-and-expressions

Added support for expressions and UI errors
This commit is contained in:
Orange Mug 2020-03-27 17:51:02 +00:00 committed by GitHub
commit 8c82db9162
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1262 additions and 240 deletions

17
package-lock.json generated
View file

@ -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": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
@ -5783,8 +5788,7 @@
"grapheme-splitter": { "grapheme-splitter": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
"dev": true
}, },
"grid-index": { "grid-index": {
"version": "1.1.0", "version": "1.1.0",
@ -7141,6 +7145,15 @@
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
"dev": true "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": { "json3": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz",

View file

@ -30,6 +30,7 @@
"color": "^3.1.2", "color": "^3.1.2",
"detect-browser": "^4.8.0", "detect-browser": "^4.8.0",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"json-to-ast": "^2.1.0",
"jsonlint": "github:josdejong/jsonlint#85a19d7", "jsonlint": "github:josdejong/jsonlint#85a19d7",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",

View file

@ -3,6 +3,7 @@ import React from 'react'
import cloneDeep from 'lodash.clonedeep' import cloneDeep from 'lodash.clonedeep'
import clamp from 'lodash.clamp' import clamp from 'lodash.clamp'
import get from 'lodash.get' import get from 'lodash.get'
import {unset} from 'lodash'
import {arrayMove} from 'react-sortable-hoc' import {arrayMove} from 'react-sortable-hoc'
import url from 'url' import url from 'url'
@ -97,7 +98,7 @@ export default class App extends React.Component {
port = window.location.port port = window.location.port
} }
this.styleStore = new ApiStyleStore({ this.styleStore = new ApiStyleStore({
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, false), onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
port: port, port: port,
host: params.get("localhost") host: params.get("localhost")
}) })
@ -316,10 +317,57 @@ export default class App extends React.Component {
this.onStyleChanged(changedStyle) this.onStyleChanged(changedStyle)
} }
onStyleChanged = (newStyle, save=true) => { onStyleChanged = (newStyle, opts={}) => {
opts = {
save: true,
addRevision: true,
...opts,
};
const errors = validate(newStyle, latest) const errors = validate(newStyle, latest) || [];
if(errors.length === 0) { 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,
};
}
})
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) { if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
this.updateFonts(newStyle.glyphs) this.updateFonts(newStyle.glyphs)
@ -328,27 +376,28 @@ export default class App extends React.Component {
this.updateIcons(newStyle.sprite) this.updateIcons(newStyle.sprite)
} }
this.revisionStore.addRevision(newStyle) if (opts.addRevision) {
if(save) this.saveStyle(newStyle) this.revisionStore.addRevision(newStyle);
}
if (opts.save) {
this.saveStyle(newStyle);
}
this.setState({ this.setState({
mapStyle: newStyle, mapStyle: newStyle,
errors: [], dirtyMapStyle: dirtyMapStyle,
errors: mappedErrors,
}) })
} else {
this.setState({
errors: errors.map(err => err.message)
})
}
this.fetchSources(); this.fetchSources();
} }
onUndo = () => { onUndo = () => {
const activeStyle = this.revisionStore.undo() const activeStyle = this.revisionStore.undo()
const messages = undoMessages(this.state.mapStyle, activeStyle) const messages = undoMessages(this.state.mapStyle, activeStyle)
this.saveStyle(activeStyle) this.onStyleChanged(activeStyle, {addRevision: false});
this.setState({ this.setState({
mapStyle: activeStyle,
infos: messages, infos: messages,
}) })
} }
@ -356,9 +405,8 @@ export default class App extends React.Component {
onRedo = () => { onRedo = () => {
const activeStyle = this.revisionStore.redo() const activeStyle = this.revisionStore.redo()
const messages = redoMessages(this.state.mapStyle, activeStyle) const messages = redoMessages(this.state.mapStyle, activeStyle)
this.saveStyle(activeStyle) this.onStyleChanged(activeStyle, {addRevision: false});
this.setState({ this.setState({
mapStyle: activeStyle,
infos: messages, infos: messages,
}) })
} }
@ -546,10 +594,11 @@ export default class App extends React.Component {
} }
mapRenderer() { mapRenderer() {
const {mapStyle, dirtyMapStyle} = this.state;
const metadata = this.state.mapStyle.metadata || {}; const metadata = this.state.mapStyle.metadata || {};
const mapProps = { const mapProps = {
mapStyle: this.state.mapStyle, mapStyle: (dirtyMapStyle || mapStyle),
replaceAccessTokens: (mapStyle) => { replaceAccessTokens: (mapStyle) => {
return style.replaceAccessTokens(mapStyle, { return style.replaceAccessTokens(mapStyle, {
allowFallback: true allowFallback: true
@ -663,6 +712,7 @@ export default class App extends React.Component {
selectedLayerIndex={this.state.selectedLayerIndex} selectedLayerIndex={this.state.selectedLayerIndex}
layers={layers} layers={layers}
sources={this.state.sources} sources={this.state.sources}
errors={this.state.errors}
/> />
const layerEditor = selectedLayer ? <LayerEditor const layerEditor = selectedLayer ? <LayerEditor
@ -680,9 +730,13 @@ export default class App extends React.Component {
onLayerCopy={this.onLayerCopy} onLayerCopy={this.onLayerCopy}
onLayerVisibilityToggle={this.onLayerVisibilityToggle} onLayerVisibilityToggle={this.onLayerVisibilityToggle}
onLayerIdChange={this.onLayerIdChange} onLayerIdChange={this.onLayerIdChange}
errors={this.state.errors}
/> : null /> : null
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
currentLayer={selectedLayer}
onLayerSelect={this.onLayerSelect}
mapStyle={this.state.mapStyle}
errors={this.state.errors} errors={this.state.errors}
infos={this.state.infos} infos={this.state.infos}
/> : null /> : null

View file

@ -5,11 +5,45 @@ class MessagePanel extends React.Component {
static propTypes = { static propTypes = {
errors: PropTypes.array, errors: PropTypes.array,
infos: PropTypes.array, infos: PropTypes.array,
mapStyle: PropTypes.object,
onLayerSelect: PropTypes.func,
currentLayer: PropTypes.object,
}
static defaultProps = {
onLayerSelect: () => {},
} }
render() { render() {
const errors = this.props.errors.map((m, i) => { const errors = this.props.errors.map((error, idx) => {
return <p key={"error-"+i} className="maputnik-message-panel-error">{m}</p> 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 <span>&apos;{layerId}&apos;</span>: {parsed.data.message}
{currentLayer.id !== layerId &&
<>
&nbsp;&mdash;&nbsp;
<button
className="maputnik-message-panel__switch-button"
onClick={() => this.props.onLayerSelect(layerId)}
>
switch to layer
</button>
</>
}
</>
);
}
else {
content = error.message;
}
return <p key={"error-"+idx} className="maputnik-message-panel-error">
{content}
</p>
}) })
const infos = this.props.infos.map((m, i) => { const infos = this.props.infos.map((m, i) => {

View file

@ -49,8 +49,15 @@ export default class DocLabel extends React.Component {
</div> </div>
</label> </label>
} }
else if (label) {
return <label className="maputnik-doc-wrapper">
<div className="maputnik-doc-target">
{label}
</div>
</label>
}
else { else {
return <div /> <div />
} }
} }
} }

View file

@ -4,14 +4,78 @@ import PropTypes from 'prop-types'
import SpecProperty from './_SpecProperty' import SpecProperty from './_SpecProperty'
import DataProperty from './_DataProperty' import DataProperty from './_DataProperty'
import ZoomProperty from './_ZoomProperty' 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) { 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) { 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 = { static propTypes = {
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
fieldName: PropTypes.string.isRequired, fieldName: PropTypes.string.isRequired,
fieldType: PropTypes.string.isRequired,
fieldSpec: PropTypes.object.isRequired, fieldSpec: PropTypes.object.isRequired,
errors: PropTypes.object,
value: PropTypes.oneOfType([ value: PropTypes.oneOfType([
PropTypes.object, 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) { getFieldFunctionType(fieldSpec) {
if (fieldSpec.expression.interpolated) { if (fieldSpec.expression.interpolated) {
return "exponential" return "exponential"
@ -82,6 +169,14 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, changedValue) 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) => { deleteStop = (stopIdx) => {
const stops = this.props.value.stops.slice(0) const stops = this.props.value.stops.slice(0)
stops.splice(stopIdx, 1) stops.splice(stopIdx, 1)
@ -108,6 +203,39 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, zoomFunc) 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 = () => { makeDataFunction = () => {
const functionType = this.getFieldFunctionType(this.props.fieldSpec); const functionType = this.getFieldFunctionType(this.props.fieldSpec);
const stopValue = functionType === 'categorical' ? '' : 0; const stopValue = functionType === 'categorical' ? '' : 0;
@ -122,43 +250,78 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, dataFunc) this.props.onChange(this.props.fieldName, dataFunc)
} }
onMarkEditing = () => {
this.setState({isEditing: true});
}
onUnmarkEditing = () => {
this.setState({isEditing: false});
}
render() { render() {
const {dataType} = this.state;
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property" const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
let specField; let specField;
if (isZoomField(this.props.value)) { if (dataType === "expression") {
specField = (
<ExpressionProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this, this.props.fieldName)}
canUndo={this.canUndo}
onUndo={this.undoExpression}
onDelete={this.deleteExpression}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value}
onFocus={this.onMarkEditing}
onBlur={this.onUnmarkEditing}
/>
);
}
else if (dataType === "zoom_function") {
specField = ( specField = (
<ZoomProperty <ZoomProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)} onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName} fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
value={this.props.value} value={this.props.value}
onDeleteStop={this.deleteStop} onDeleteStop={this.deleteStop}
onAddStop={this.addStop} onAddStop={this.addStop}
onExpressionClick={this.makeExpression}
/> />
) )
} }
else if (isDataField(this.props.value)) { else if (dataType === "data_function") {
specField = ( specField = (
<DataProperty <DataProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)} onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName} fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
value={this.props.value} value={this.props.value}
onDeleteStop={this.deleteStop} onDeleteStop={this.deleteStop}
onAddStop={this.addStop} onAddStop={this.addStop}
onExpressionClick={this.makeExpression}
/> />
) )
} }
else { else {
specField = ( specField = (
<SpecProperty <SpecProperty
errors={this.props.errors}
onChange={this.props.onChange.bind(this)} onChange={this.props.onChange.bind(this)}
fieldType={this.props.fieldType}
fieldName={this.props.fieldName} fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
value={this.props.value} value={this.props.value}
onZoomClick={this.makeZoomFunction} onZoomClick={this.makeZoomFunction}
onDataClick={this.makeDataFunction} onDataClick={this.makeDataFunction}
onExpressionClick={this.makeExpression}
/> />
) )
} }

View file

@ -40,6 +40,7 @@ export default class PropertyGroup extends React.Component {
groupFields: PropTypes.array.isRequired, groupFields: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
spec: PropTypes.object.isRequired, spec: PropTypes.object.isRequired,
errors: PropTypes.object,
} }
onPropertyChange = (property, newValue) => { onPropertyChange = (property, newValue) => {
@ -48,18 +49,22 @@ export default class PropertyGroup extends React.Component {
} }
render() { render() {
const {errors} = this.props;
const fields = this.props.groupFields.map(fieldName => { const fields = this.props.groupFields.map(fieldName => {
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName) const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName)
const paint = this.props.layer.paint || {} const paint = this.props.layer.paint || {}
const layout = this.props.layer.layout || {} const layout = this.props.layer.layout || {}
const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName] const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName]
const fieldType = fieldName in paint ? 'paint' : 'layout';
return <FunctionSpecField return <FunctionSpecField
errors={errors}
onChange={this.onPropertyChange} onChange={this.onPropertyChange}
key={fieldName} key={fieldName}
fieldName={fieldName} fieldName={fieldName}
value={fieldValue} value={fieldValue}
fieldType={fieldType}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
/> />
}) })

View file

@ -39,7 +39,9 @@ export default class DataProperty extends React.Component {
onChange: PropTypes.func, onChange: PropTypes.func,
onDeleteStop: PropTypes.func, onDeleteStop: PropTypes.func,
onAddStop: PropTypes.func, onAddStop: PropTypes.func,
onExpressionClick: PropTypes.func,
fieldName: PropTypes.string, fieldName: PropTypes.string,
fieldType: PropTypes.string,
fieldSpec: PropTypes.object, fieldSpec: PropTypes.object,
value: PropTypes.oneOfType([ value: PropTypes.oneOfType([
PropTypes.object, PropTypes.object,
@ -48,6 +50,7 @@ export default class DataProperty extends React.Component {
PropTypes.bool, PropTypes.bool,
PropTypes.array PropTypes.array
]), ]),
errors: PropTypes.object,
} }
state = { state = {
@ -144,6 +147,8 @@ export default class DataProperty extends React.Component {
} }
render() { render() {
const {fieldName, fieldType, errors} = this.props;
if (typeof this.props.value.type === "undefined") { if (typeof this.props.value.type === "undefined") {
this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec) this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec)
} }
@ -181,7 +186,22 @@ export default class DataProperty extends React.Component {
</div> </div>
} }
return <InputBlock key={key} action={deleteStopBtn} label=""> 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 <InputBlock
error={error}
key={key}
action={deleteStopBtn}
label=""
>
{zoomInput} {zoomInput}
<div className="maputnik-data-spec-property-stop-data"> <div className="maputnik-data-spec-property-stop-data">
{dataInput} {dataInput}
@ -241,6 +261,8 @@ export default class DataProperty extends React.Component {
/> />
</div> </div>
</div> </div>
</InputBlock>
</div>
{dataFields} {dataFields}
<Button <Button
className="maputnik-add-stop" className="maputnik-add-stop"
@ -248,8 +270,12 @@ export default class DataProperty extends React.Component {
> >
Add stop Add stop
</Button> </Button>
</InputBlock> <Button
</div> className="maputnik-add-stop"
onClick={this.props.onExpressionClick.bind(this)}
>
Convert to expression
</Button>
</div> </div>
} }
} }

View file

@ -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 &&
<Button
key="undo_action"
onClick={this.props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
>
<MdUndo />
</Button>
}
<Button
key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop"
>
<MdDelete />
</Button>
</>
);
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 <InputBlock
error={foundErrors}
fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(this.props.fieldName)}
action={deleteStopBtn}
wideMode={true}
>
<JSONEditor
mode={{name: "mgl"}}
lint={{
context: "expression",
spec: this.props.fieldSpec,
}}
className="maputnik-expression-editor"
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onJSONInvalid={this.onJSONInvalid}
onJSONValid={this.onJSONValid}
layer={value}
lineNumbers={false}
maxHeight={200}
lineWrapping={true}
getValue={getValue}
onChange={this.props.onChange}
/>
</InputBlock>
}
}

View file

@ -3,18 +3,50 @@ import PropTypes from 'prop-types'
import Button from '../Button' import Button from '../Button'
import {MdFunctions, MdInsertChart} from 'react-icons/md' 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 { export default class FunctionButtons extends React.Component {
static propTypes = { static propTypes = {
fieldSpec: PropTypes.object, fieldSpec: PropTypes.object,
onZoomClick: PropTypes.func, onZoomClick: PropTypes.func,
onDataClick: PropTypes.func, onDataClick: PropTypes.func,
onExpressionClick: PropTypes.func,
} }
render() { render() {
let makeZoomButton, makeDataButton let makeZoomButton, makeDataButton, expressionButton;
if (this.props.fieldSpec.expression.parameters.includes('zoom')) { if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
expressionButton = (
<Button
className="maputnik-make-zoom-function"
onClick={this.props.onExpressionClick}
>
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
</Button>
);
makeZoomButton = <Button makeZoomButton = <Button
className="maputnik-make-zoom-function" className="maputnik-make-zoom-function"
onClick={this.props.onZoomClick} onClick={this.props.onZoomClick}
@ -32,10 +64,14 @@ export default class FunctionButtons extends React.Component {
<MdInsertChart /> <MdInsertChart />
</Button> </Button>
} }
return <div>{makeDataButton}{makeZoomButton}</div> return <div>
{expressionButton}
{makeDataButton}
{makeZoomButton}
</div>
} }
else { else {
return null return <div>{expressionButton}</div>
} }
} }
} }

View file

@ -13,18 +13,32 @@ export default class SpecProperty extends React.Component {
onZoomClick: PropTypes.func.isRequired, onZoomClick: PropTypes.func.isRequired,
onDataClick: PropTypes.func.isRequired, onDataClick: PropTypes.func.isRequired,
fieldName: PropTypes.string, 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() { render() {
const {errors, fieldName, fieldType} = this.props;
const functionBtn = <FunctionButtons const functionBtn = <FunctionButtons
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
onZoomClick={this.props.onZoomClick} onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick} onDataClick={this.props.onDataClick}
value={this.props.value}
onExpressionClick={this.props.onExpressionClick}
/> />
const error = errors[fieldType+"."+fieldName];
return <InputBlock return <InputBlock
doc={this.props.fieldSpec.doc} error={error}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(this.props.fieldName)} label={labelFromFieldName(this.props.fieldName)}
action={functionBtn} action={functionBtn}

View file

@ -42,8 +42,11 @@ export default class ZoomProperty extends React.Component {
onChange: PropTypes.func, onChange: PropTypes.func,
onDeleteStop: PropTypes.func, onDeleteStop: PropTypes.func,
onAddStop: PropTypes.func, onAddStop: PropTypes.func,
onExpressionClick: PropTypes.func,
fieldType: PropTypes.string,
fieldName: PropTypes.string, fieldName: PropTypes.string,
fieldSpec: PropTypes.object, fieldSpec: PropTypes.object,
errors: PropTypes.object,
value: PropTypes.oneOfType([ value: PropTypes.oneOfType([
PropTypes.object, PropTypes.object,
PropTypes.string, PropTypes.string,
@ -53,6 +56,10 @@ export default class ZoomProperty extends React.Component {
]), ]),
} }
static defaultProps = {
errors: {},
}
state = { state = {
refs: {} refs: {}
} }
@ -117,13 +124,26 @@ export default class ZoomProperty extends React.Component {
} }
render() { render() {
const {fieldName, fieldType, errors} = this.props;
const zoomFields = this.props.value.stops.map((stop, idx) => { const zoomFields = this.props.value.stops.map((stop, idx) => {
const zoomLevel = stop[0] const zoomLevel = stop[0]
const key = this.state.refs[idx]; const key = this.state.refs[idx];
const value = stop[1] const value = stop[1]
const deleteStopBtn= <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} /> const deleteStopBtn= <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} />
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 <InputBlock return <InputBlock
error={error}
key={key} key={key}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(this.props.fieldName)} label={labelFromFieldName(this.props.fieldName)}
@ -158,6 +178,12 @@ export default class ZoomProperty extends React.Component {
> >
Add stop Add stop
</Button> </Button>
<Button
className="maputnik-add-stop"
onClick={this.props.onExpressionClick.bind(this)}
>
Convert to expression
</Button>
</div> </div>
} }
} }

View file

@ -1,6 +1,13 @@
import capitalize from 'lodash.capitalize' import capitalize from 'lodash.capitalize'
export default function labelFromFieldName(fieldName) { export default function labelFromFieldName(fieldName) {
let label = fieldName.split('-').slice(1).join(' ') let label;
return capitalize(label) const parts = fieldName.split('-');
if (parts.length > 1) {
label = fieldName.split('-').slice(1).join(' ');
}
else {
label = fieldName;
}
return capitalize(label);
} }

View file

@ -2,13 +2,84 @@ import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { combiningFilterOps } from '../../libs/filterops.js' 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 DocLabel from '../fields/DocLabel'
import SelectInput from '../inputs/SelectInput' import SelectInput from '../inputs/SelectInput'
import InputBlock from '../inputs/InputBlock'
import SingleFilterEditor from './SingleFilterEditor' import SingleFilterEditor from './SingleFilterEditor'
import FilterEditorBlock from './FilterEditorBlock' import FilterEditorBlock from './FilterEditorBlock'
import Button from '../Button' import Button from '../Button'
import SpecDoc from '../inputs/SpecDoc' 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) { function hasCombiningFilter(filter) {
return combiningFilterOps.indexOf(filter[0]) >= 0 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 of the vector layer and the available fields */
properties: PropTypes.object, properties: PropTypes.object,
filter: PropTypes.array, filter: PropTypes.array,
errors: PropTypes.object,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
} }
constructor () { static defaultProps = {
filter: ["all"],
}
constructor (props) {
super(); super();
this.state = { this.state = {
showDoc: false, showDoc: false,
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
}; };
} }
// Convert filter to combining filter // 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) { onFilterPartChanged(filterIdx, newPart) {
const newFilter = this.combiningFilter().slice(0) const newFilter = combiningFilter(this.props).slice(0)
newFilter[filterIdx] = newPart newFilter[filterIdx] = newPart
this.props.onChange(newFilter) this.props.onChange(newFilter)
} }
deleteFilterItem(filterIdx) { deleteFilterItem(filterIdx) {
const newFilter = this.combiningFilter().slice(0) const newFilter = combiningFilter(this.props).slice(0)
console.log('Delete', filterIdx, newFilter)
newFilter.splice(filterIdx + 1, 1) newFilter.splice(filterIdx + 1, 1)
this.props.onChange(newFilter) this.props.onChange(newFilter)
} }
addFilterItem = () => { addFilterItem = () => {
const newFilterItem = this.combiningFilter().slice(0) const newFilterItem = combiningFilter(this.props).slice(0)
newFilterItem.push(['==', 'name', '']) newFilterItem.push(['==', 'name', ''])
this.props.onChange(newFilterItem) this.props.onChange(newFilterItem)
} }
@ -77,47 +139,125 @@ export default class CombiningFilterEditor extends React.Component {
}); });
} }
render() { makeFilter = () => {
const filter = this.combiningFilter() this.setState({
let combiningOp = filter[0] displaySimpleFilter: true,
let filters = filter.slice(1) })
}
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={ const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter." doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
}; };
const defaultFilter = ["all"];
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
Nested filters are not supported.
</p>
<Button
onClick={this.makeExpression}
>
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
Upgrade to expression
</Button>
</div>
}
else if (displaySimpleFilter) {
const filter = combiningFilter(this.props);
let combiningOp = filter[0];
let filters = filter.slice(1)
const actions = (
<div>
<Button
onClick={this.makeExpression}
className="maputnik-make-zoom-function"
>
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
</Button>
</div>
);
const editorBlocks = filters.map((f, idx) => { const editorBlocks = filters.map((f, idx) => {
return <FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}> const error = errors[`filter[${idx+1}]`];
return (
<>
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor <SingleFilterEditor
properties={this.props.properties} properties={this.props.properties}
filter={f} filter={f}
onChange={this.onFilterPartChanged.bind(this, idx + 1)} onChange={this.onFilterPartChanged.bind(this, idx + 1)}
/> />
</FilterEditorBlock> </FilterEditorBlock>
{error &&
<div className="maputnik-inline-error">{error.message}</div>
}
</>
);
}) })
//TODO: Implement support for nested filter
if(hasNestedCombiningFilter(filter)) {
return <div className="maputnik-filter-editor-unsupported">
Nested filters are not supported.
</div>
}
return <div className="maputnik-filter-editor"> return (
<div className="maputnik-filter-editor-compound-select" data-wd-key="layer-filter"> <>
<DocLabel <InputBlock
label={"Compound Filter"} key="top"
onToggleDoc={this.onToggleDoc}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
/> label={"Filter"}
action={actions}
>
<SelectInput <SelectInput
value={combiningOp} value={combiningOp}
onChange={this.onFilterPartChanged.bind(this, 0)} onChange={this.onFilterPartChanged.bind(this, 0)}
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]} options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
/> />
</div> </InputBlock>
{editorBlocks} {editorBlocks}
<div className="maputnik-filter-editor-add-wrapper"> <div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<Button <Button
data-wd-key="layer-filter-button" data-wd-key="layer-filter-button"
className="maputnik-add-filter" className="maputnik-add-filter"
@ -126,11 +266,44 @@ export default class CombiningFilterEditor extends React.Component {
</Button> </Button>
</div> </div>
<div <div
key="doc"
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}} style={{display: this.state.showDoc ? '' : 'none'}}
> >
<SpecDoc fieldSpec={fieldSpec} /> <SpecDoc fieldSpec={fieldSpec} />
</div> </div>
</>
);
}
else {
let {filter} = this.props;
return (
<>
<ExpressionProperty
onDelete={() => {
this.setState({displaySimpleFilter: true});
this.props.onChange(defaultFilter);
}}
fieldName="filter"
fieldSpec={fieldSpec}
value={filter}
errors={errors}
onChange={this.props.onChange}
/>
{this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox">
You&apos;ve entered a old style filter,{' '}
<button
onClick={this.makeFilter}
className="maputnik-expr-infobox__button"
>
switch to filter editor
</button>
</div> </div>
} }
</>
);
}
}
} }

View file

@ -6,6 +6,7 @@ import FillIcon from './FillIcon.jsx'
import SymbolIcon from './SymbolIcon.jsx' import SymbolIcon from './SymbolIcon.jsx'
import BackgroundIcon from './BackgroundIcon.jsx' import BackgroundIcon from './BackgroundIcon.jsx'
import CircleIcon from './CircleIcon.jsx' import CircleIcon from './CircleIcon.jsx'
import MissingIcon from './MissingIcon.jsx'
class LayerIcon extends React.Component { class LayerIcon extends React.Component {
static propTypes = { static propTypes = {
@ -25,6 +26,7 @@ class LayerIcon extends React.Component {
case 'line': return <LineIcon {...iconProps} /> case 'line': return <LineIcon {...iconProps} />
case 'symbol': return <SymbolIcon {...iconProps} /> case 'symbol': return <SymbolIcon {...iconProps} />
case 'circle': return <CircleIcon {...iconProps} /> case 'circle': return <CircleIcon {...iconProps} />
default: return <MissingIcon {...iconProps} />
} }
} }
} }

View file

@ -0,0 +1,11 @@
import React from 'react'
import {MdPriorityHigh} from 'react-icons/md'
export default class MissingIcon extends React.Component {
render() {
return (
<MdPriorityHigh {...this.props} />
)
}
}

View file

@ -18,6 +18,8 @@ class InputBlock extends React.Component {
style: PropTypes.object, style: PropTypes.object,
onChange: PropTypes.func, onChange: PropTypes.func,
fieldSpec: PropTypes.object, fieldSpec: PropTypes.object,
wideMode: PropTypes.bool,
error: PropTypes.array,
} }
constructor (props) { constructor (props) {
@ -39,10 +41,13 @@ class InputBlock extends React.Component {
} }
render() { render() {
const errors = [].concat(this.props.error || []);
return <div style={this.props.style} return <div style={this.props.style}
data-wd-key={this.props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
className={classnames({ className={classnames({
"maputnik-input-block": true, "maputnik-input-block": true,
"maputnik-input-block--wide": this.props.wideMode,
"maputnik-action-block": this.props.action "maputnik-action-block": this.props.action
})} })}
> >
@ -68,6 +73,13 @@ class InputBlock extends React.Component {
<div className="maputnik-input-block-content"> <div className="maputnik-input-block-content">
{this.props.children} {this.props.children}
</div> </div>
{errors.length > 0 &&
<div className="maputnik-inline-error">
{[].concat(this.props.error).map((error, idx) => {
return <div key={idx}>{error.message}</div>
})}
</div>
}
{this.props.fieldSpec && {this.props.fieldSpec &&
<div <div
className="maputnik-doc-inline" className="maputnik-doc-inline"

View file

@ -11,6 +11,8 @@ class StringInput extends React.Component {
onInput: PropTypes.func, onInput: PropTypes.func,
multi: PropTypes.bool, multi: PropTypes.bool,
required: PropTypes.bool, required: PropTypes.bool,
disabled: PropTypes.bool,
spellCheck: PropTypes.bool,
} }
static defaultProps = { static defaultProps = {
@ -51,9 +53,14 @@ class StringInput extends React.Component {
] ]
} }
if(!!this.props.disabled) {
classes.push("maputnik-string--disabled");
}
return React.createElement(tag, { return React.createElement(tag, {
"data-wd-key": this.props["data-wd-key"], "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(" "), className: classes.join(" "),
style: this.props.style, style: this.props.style,
value: this.state.value === undefined ? "" : this.state.value, value: this.state.value === undefined ? "" : this.state.value,

View file

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import classnames from 'classnames';
import InputBlock from '../inputs/InputBlock' import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput' import StringInput from '../inputs/StringInput'
@ -11,44 +12,68 @@ import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/lib/codemirror.css' import 'codemirror/lib/codemirror.css'
import 'codemirror/addon/lint/lint.css' import 'codemirror/addon/lint/lint.css'
import jsonlint from 'jsonlint' import jsonlint from 'jsonlint'
import stringifyPretty from 'json-stringify-pretty-compact'
// This is mainly because of this issue <https://github.com/zaach/jsonlint/issues/57> also the API has changed, see comment in file import '../util/codemirror-mgl';
import '../../vendor/codemirror/addon/lint/json-lint'
class JSONEditor extends React.Component { class JSONEditor extends React.Component {
static propTypes = { static propTypes = {
layer: PropTypes.object.isRequired, layer: PropTypes.any.isRequired,
maxHeight: PropTypes.number, maxHeight: PropTypes.number,
onChange: PropTypes.func, 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) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
isEditing: false, isEditing: false,
prevValue: this.getValue(), prevValue: this.props.getValue(this.props.layer),
}; };
} }
getValue () {
return JSON.stringify(this.props.layer, null, 2);
}
componentDidMount () { componentDidMount () {
this._doc = CodeMirror(this._el, { this._doc = CodeMirror(this._el, {
value: this.getValue(), value: this.props.getValue(this.props.layer),
mode: { mode: this.props.mode || {
name: "javascript", name: "mgl",
json: true
}, },
lineWrapping: this.props.lineWrapping,
tabSize: 2, tabSize: 2,
theme: 'maputnik', theme: 'maputnik',
viewportMargin: Infinity, viewportMargin: Infinity,
lineNumbers: true, lineNumbers: this.props.lineNumbers,
lint: true, lint: this.props.lint || {
context: "layer"
},
matchBrackets: true, matchBrackets: true,
gutters: ["CodeMirror-lint-markers"], gutters: this.props.gutters,
scrollbarStyle: "null", scrollbarStyle: "null",
}); });
@ -58,12 +83,14 @@ class JSONEditor extends React.Component {
} }
onFocus = () => { onFocus = () => {
this.props.onFocus();
this.setState({ this.setState({
isEditing: true isEditing: true
}); });
} }
onBlur = () => { onBlur = () => {
this.props.onBlur();
this.setState({ this.setState({
isEditing: false isEditing: false
}); });
@ -79,7 +106,7 @@ class JSONEditor extends React.Component {
if (!this.state.isEditing && prevProps.layer !== this.props.layer) { if (!this.state.isEditing && prevProps.layer !== this.props.layer) {
this._cancelNextChange = true; this._cancelNextChange = true;
this._doc.setValue( this._doc.setValue(
this.getValue(), this.props.getValue(this.props.layer),
) )
} }
} }
@ -87,16 +114,28 @@ class JSONEditor extends React.Component {
onChange = (e) => { onChange = (e) => {
if (this._cancelNextChange) { if (this._cancelNextChange) {
this._cancelNextChange = false; this._cancelNextChange = false;
this.setState({
prevValue: this._doc.getValue(),
})
return; return;
} }
const newCode = this._doc.getValue(); const newCode = this._doc.getValue();
if (this.state.prevValue !== newCode) { if (this.state.prevValue !== newCode) {
let parsedLayer, err;
try { 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) this.props.onChange(parsedLayer)
} catch(err) { this.props.onJSONValid();
console.warn(err)
} }
} }
@ -112,7 +151,7 @@ class JSONEditor extends React.Component {
} }
return <div return <div
className="codemirror-container" className={classnames("codemirror-container", this.props.className)}
ref={(el) => this._el = el} ref={(el) => this._el = el}
style={style} style={style}
/> />

View file

@ -20,6 +20,10 @@ import { changeType, changeProperty } from '../../libs/layer'
import layout from '../../config/layout.json' import layout from '../../config/layout.json'
function getLayoutForType (type) {
return layout[type] ? layout[type] : layout.invalid;
}
function layoutGroups(layerType) { function layoutGroups(layerType) {
const layerGroup = { const layerGroup = {
title: 'Layer', title: 'Layer',
@ -33,7 +37,9 @@ function layoutGroups(layerType) {
title: 'JSON Editor', title: 'JSON Editor',
type: 'jsoneditor' 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. */ /** Layer editor supporting multiple types of layers. */
@ -52,6 +58,7 @@ export default class LayerEditor extends React.Component {
isFirstLayer: PropTypes.bool, isFirstLayer: PropTypes.bool,
isLastLayer: PropTypes.bool, isLastLayer: PropTypes.bool,
layerIndex: PropTypes.number, layerIndex: PropTypes.number,
errors: PropTypes.array,
} }
static defaultProps = { static defaultProps = {
@ -79,7 +86,7 @@ export default class LayerEditor extends React.Component {
static getDerivedStateFromProps(props, state) { static getDerivedStateFromProps(props, state) {
const additionalGroups = { ...state.editorGroups } const additionalGroups = { ...state.editorGroups }
layout[props.layer.type].groups.forEach(group => { getLayoutForType(props.layer.type).groups.forEach(group => {
if(!(group.title in additionalGroups)) { if(!(group.title in additionalGroups)) {
additionalGroups[group.title] = true additionalGroups[group.title] = true
} }
@ -118,6 +125,20 @@ export default class LayerEditor extends React.Component {
if(this.props.layer.metadata) { if(this.props.layer.metadata) {
comment = this.props.layer.metadata['maputnik:comment'] 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; let sourceLayerIds;
if(this.props.sources.hasOwnProperty(this.props.layer.source)) { if(this.props.sources.hasOwnProperty(this.props.layer.source)) {
@ -129,13 +150,17 @@ export default class LayerEditor extends React.Component {
<LayerIdBlock <LayerIdBlock
value={this.props.layer.id} value={this.props.layer.id}
wdKey="layer-editor.layer-id" wdKey="layer-editor.layer-id"
error={errorData.id}
onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)} onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
/> />
<LayerTypeBlock <LayerTypeBlock
disabled={true}
error={errorData.type}
value={this.props.layer.type} value={this.props.layer.type}
onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))} onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))}
/> />
{this.props.layer.type !== 'background' && <LayerSourceBlock {this.props.layer.type !== 'background' && <LayerSourceBlock
error={errorData.sources}
sourceIds={Object.keys(this.props.sources)} sourceIds={Object.keys(this.props.sources)}
value={this.props.layer.source} value={this.props.layer.source}
onChange={v => this.changeProperty(null, 'source', v)} onChange={v => 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 && {['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
<LayerSourceLayerBlock <LayerSourceLayerBlock
error={errorData['source-layer']}
sourceLayerIds={sourceLayerIds} sourceLayerIds={sourceLayerIds}
value={this.props.layer['source-layer']} value={this.props.layer['source-layer']}
onChange={v => this.changeProperty(null, 'source-layer', v)} onChange={v => this.changeProperty(null, 'source-layer', v)}
/> />
} }
<MinZoomBlock <MinZoomBlock
error={errorData.minzoom}
value={this.props.layer.minzoom} value={this.props.layer.minzoom}
onChange={v => this.changeProperty(null, 'minzoom', v)} onChange={v => this.changeProperty(null, 'minzoom', v)}
/> />
<MaxZoomBlock <MaxZoomBlock
error={errorData.maxzoom}
value={this.props.layer.maxzoom} value={this.props.layer.maxzoom}
onChange={v => this.changeProperty(null, 'maxzoom', v)} onChange={v => this.changeProperty(null, 'maxzoom', v)}
/> />
<CommentBlock <CommentBlock
error={errorData.comment}
value={comment} value={comment}
onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)} onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)}
/> />
@ -164,6 +193,7 @@ export default class LayerEditor extends React.Component {
case 'filter': return <div> case 'filter': return <div>
<div className="maputnik-filter-editor-wrapper"> <div className="maputnik-filter-editor-wrapper">
<FilterEditor <FilterEditor
errors={errorData}
filter={this.props.layer.filter} filter={this.props.layer.filter}
properties={this.props.vectorLayers[this.props.layer['source-layer']]} properties={this.props.vectorLayers[this.props.layer['source-layer']]}
onChange={f => this.changeProperty(null, 'filter', f)} onChange={f => this.changeProperty(null, 'filter', f)}
@ -171,6 +201,7 @@ export default class LayerEditor extends React.Component {
</div> </div>
</div> </div>
case 'properties': return <PropertyGroup case 'properties': return <PropertyGroup
errors={errorData}
layer={this.props.layer} layer={this.props.layer}
groupFields={fields} groupFields={fields}
spec={this.props.spec} spec={this.props.spec}

View file

@ -181,10 +181,19 @@ class LayerListContainer extends React.Component {
layers.forEach((layer, idxInGroup) => { layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(this.props.layers, idx) 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 = <LayerListItem const listItem = <LayerListItem
className={classnames({ className={classnames({
'maputnik-layer-list-item-collapsed': layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex, 'maputnik-layer-list-item-collapsed': layers.length > 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} index={idx}
key={layer.id} key={layer.id}

View file

@ -4,18 +4,33 @@ import PropTypes from 'prop-types'
import {latest} from '@mapbox/mapbox-gl-style-spec' import {latest} from '@mapbox/mapbox-gl-style-spec'
import InputBlock from '../inputs/InputBlock' import InputBlock from '../inputs/InputBlock'
import SelectInput from '../inputs/SelectInput' import SelectInput from '../inputs/SelectInput'
import StringInput from '../inputs/StringInput'
class LayerTypeBlock extends React.Component { class LayerTypeBlock extends React.Component {
static propTypes = { static propTypes = {
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
wdKey: PropTypes.string, wdKey: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
error: PropTypes.object,
disabled: PropTypes.bool,
}
static defaultProps = {
disabled: false,
} }
render() { render() {
return <InputBlock label={"Type"} fieldSpec={latest.layer.type} return <InputBlock label={"Type"} fieldSpec={latest.layer.type}
data-wd-key={this.props.wdKey} data-wd-key={this.props.wdKey}
error={this.props.error}
> >
{this.props.disabled &&
<StringInput
value={this.props.value}
disabled={true}
/>
}
{!this.props.disabled &&
<SelectInput <SelectInput
options={[ options={[
['background', 'Background'], ['background', 'Background'],
@ -31,6 +46,7 @@ class LayerTypeBlock extends React.Component {
onChange={this.props.onChange} onChange={this.props.onChange}
value={this.props.value} value={this.props.value}
/> />
}
</InputBlock> </InputBlock>
} }
} }

View file

@ -9,10 +9,12 @@ class MaxZoomBlock extends React.Component {
static propTypes = { static propTypes = {
value: PropTypes.number, value: PropTypes.number,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
error: PropTypes.object,
} }
render() { render() {
return <InputBlock label={"Max Zoom"} fieldSpec={latest.layer.maxzoom} return <InputBlock label={"Max Zoom"} fieldSpec={latest.layer.maxzoom}
error={this.props.error}
data-wd-key="max-zoom" data-wd-key="max-zoom"
> >
<NumberInput <NumberInput

View file

@ -9,10 +9,12 @@ class MinZoomBlock extends React.Component {
static propTypes = { static propTypes = {
value: PropTypes.number, value: PropTypes.number,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
error: PropTypes.object,
} }
render() { render() {
return <InputBlock label={"Min Zoom"} fieldSpec={latest.layer.minzoom} return <InputBlock label={"Min Zoom"} fieldSpec={latest.layer.minzoom}
error={this.props.error}
data-wd-key="min-zoom" data-wd-key="min-zoom"
> >
<NumberInput <NumberInput

View file

@ -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;
});

View file

@ -233,5 +233,8 @@
] ]
} }
] ]
},
"invalid": {
"groups": []
} }
} }

View file

@ -146,7 +146,7 @@
.maputnik-icon-button { .maputnik-icon-button {
background-color: transparent; background-color: transparent;
&:hover { &:hover:not(:disabled) {
background-color: transparent; background-color: transparent;
label, label,
@ -182,18 +182,26 @@
.maputnik-action-block { .maputnik-action-block {
.maputnik-input-block-label { .maputnik-input-block-label {
display: inline-block; display: inline-block;
width: 35%; width: 32%;
} }
.maputnik-input-block-action { .maputnik-input-block-action {
vertical-align: top; vertical-align: top;
display: inline-block; display: inline-block;
width: 15%; width: 18%;
} }
.maputnik-input-block-action > div { .maputnik-input-block-action > div {
text-align: right; text-align: right;
} }
}
.maputnik-data-spec-block,
.maputnik-zoom-spec-property {
.maputnik-inline-error {
margin-left: 32%;
}
} }
// SPACE HELPER // SPACE HELPER
@ -208,6 +216,12 @@
&-error { &-error {
color: $color-red; color: $color-red;
} }
&__switch-button {
all: unset;
text-decoration: underline;
cursor: pointer;
}
} }
.maputnik-dialog { .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;
}

View file

@ -1,5 +1,10 @@
.maputnik-filter-editor-wrapper { .maputnik-filter-editor-wrapper {
padding: $margin-3; padding: $margin-3;
overflow: hidden;
.maputnik-input-block {
margin: 0;
}
} }
.maputnik-filter-editor { .maputnik-filter-editor {

View file

@ -25,6 +25,11 @@
resize: vertical; resize: vertical;
height: 78px; height: 78px;
} }
&--disabled {
background: transparent;
border: none;
}
} }
.maputnik-number-container { .maputnik-number-container {

View file

@ -99,6 +99,11 @@
} }
} }
.maputnik-layer-list-item--error {
color: $color-red;
}
&-item-selected { &-item-selected {
color: $color-white; color: $color-white;
} }

View file

@ -38,7 +38,6 @@
&-bottom { &-bottom {
position: fixed; position: fixed;
height: 50px;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 1; z-index: 1;

View file

@ -2,9 +2,8 @@
.maputnik-make-zoom-function { .maputnik-make-zoom-function {
background-color: transparent; background-color: transparent;
display: inline-block; display: inline-block;
padding-bottom: 0;
padding-top: 0;
vertical-align: middle; vertical-align: middle;
padding: 0 $margin-2 0 0;
@extend .maputnik-icon-button; @extend .maputnik-icon-button;
} }
@ -63,9 +62,8 @@
.maputnik-make-data-function { .maputnik-make-data-function {
background-color: transparent; background-color: transparent;
display: inline-block; display: inline-block;
padding-bottom: 0;
padding-top: 0;
vertical-align: middle; vertical-align: middle;
padding: 0 $margin-2 0 0;
@extend .maputnik-icon-button; @extend .maputnik-icon-button;
} }
@ -98,10 +96,6 @@
.maputnik-data-spec-property-input { .maputnik-data-spec-property-input {
width: 75%; width: 75%;
display: inline-block; display: inline-block;
.maputnik-string {
margin-bottom: 3%;
}
} }
} }
} }

View file

@ -34,3 +34,4 @@
width: 14px; width: 14px;
height: 14px; height: 14px;
} }

View file

@ -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 <https://github.com/josdejong/jsonlint>
// becuase of <https://github.com/zaach/jsonlint/issues/57>
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;
});