From 8d3ad6b1a11071da7dcd1d413286b6b05b1da031 Mon Sep 17 00:00:00 2001
From: orangemug
Date: Sun, 31 May 2020 15:33:09 +0100
Subject: [PATCH 1/3] Added more functional tests.
---
src/components/App.jsx_json_modal | 832 ++++++++++++++++++++
src/components/Toolbar.jsx | 3 +
src/components/modals/DebugModal.js | 2 +-
src/components/modals/ExportModal.jsx | 2 +-
src/components/modals/LoadingModal.jsx | 2 +-
src/components/modals/OpenModal.jsx | 6 +-
src/components/modals/SettingsModal.jsx | 20 +-
src/components/modals/ShortcutsModal.jsx | 2 +-
src/components/modals/SourcesModal.jsx | 1 +
src/components/modals/SurveyModal.jsx | 2 +-
test/example-layer-style.json | 18 +
test/functional/accessibility/index.js | 3 +
test/functional/accessibility/skip-links.js | 51 ++
test/functional/history/index.js | 40 +-
test/functional/index.js | 2 +
test/functional/keyboard/index.js | 58 ++
test/functional/layers/index.js | 64 +-
test/functional/modals/index.js | 60 +-
test/geojson-server.js | 8 +
19 files changed, 1085 insertions(+), 91 deletions(-)
create mode 100644 src/components/App.jsx_json_modal
create mode 100644 test/example-layer-style.json
create mode 100644 test/functional/accessibility/index.js
create mode 100644 test/functional/accessibility/skip-links.js
create mode 100644 test/functional/keyboard/index.js
diff --git a/src/components/App.jsx_json_modal b/src/components/App.jsx_json_modal
new file mode 100644
index 00000000..ac775e08
--- /dev/null
+++ b/src/components/App.jsx_json_modal
@@ -0,0 +1,832 @@
+import autoBind from 'react-autobind';
+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'
+
+import MapboxGlMap from './map/MapboxGlMap'
+import OpenLayersMap from './map/OpenLayersMap'
+import LayerList from './layers/LayerList'
+import LayerEditor from './layers/LayerEditor'
+import Toolbar from './Toolbar'
+import AppLayout from './AppLayout'
+import MessagePanel from './MessagePanel'
+
+import SettingsModal from './modals/SettingsModal'
+import ExportModal from './modals/ExportModal'
+import SourcesModal from './modals/SourcesModal'
+import OpenModal from './modals/OpenModal'
+import ShortcutsModal from './modals/ShortcutsModal'
+import SurveyModal from './modals/SurveyModal'
+import DebugModal from './modals/DebugModal'
+import JSONEditorModal from './modals/JSONEditorModal'
+
+import { downloadGlyphsMetadata, downloadSpriteMetadata } from '../libs/metadata'
+import {latest, validate} from '@mapbox/mapbox-gl-style-spec'
+import style from '../libs/style'
+import { initialStyleUrl, loadStyleUrl, removeStyleQuerystring } from '../libs/urlopen'
+import { undoMessages, redoMessages } from '../libs/diffmessage'
+import { StyleStore } from '../libs/stylestore'
+import { ApiStyleStore } from '../libs/apistore'
+import { RevisionStore } from '../libs/revisions'
+import LayerWatcher from '../libs/layerwatcher'
+import tokens from '../config/tokens.json'
+import isEqual from 'lodash.isequal'
+import Debug from '../libs/debug'
+import queryUtil from '../libs/query-util'
+
+import MapboxGl from 'mapbox-gl'
+
+
+// Similar functionality as
+function normalizeSourceURL (url, apiToken="") {
+ const matches = url.match(/^mapbox:\/\/(.*)/);
+ if (matches) {
+ // mapbox://mapbox.mapbox-streets-v7
+ return `https://api.mapbox.com/v4/${matches[1]}.json?secure&access_token=${apiToken}`
+ }
+ else {
+ return url;
+ }
+}
+
+function setFetchAccessToken(url, mapStyle) {
+ const matchesTilehosting = url.match(/\.tilehosting\.com/);
+ const matchesMaptiler = url.match(/\.maptiler\.com/);
+ const matchesThunderforest = url.match(/\.thunderforest\.com/);
+ if (matchesTilehosting || matchesMaptiler) {
+ const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true})
+ if (accessToken) {
+ return url.replace('{key}', accessToken)
+ }
+ }
+ else if (matchesThunderforest) {
+ const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true})
+ if (accessToken) {
+ return url.replace('{key}', accessToken)
+ }
+ }
+ else {
+ return url;
+ }
+}
+
+function updateRootSpec(spec, fieldName, newValues) {
+ return {
+ ...spec,
+ $root: {
+ ...spec.$root,
+ [fieldName]: {
+ ...spec.$root[fieldName],
+ values: newValues
+ }
+ }
+ }
+}
+
+export default class App extends React.Component {
+ constructor(props) {
+ super(props)
+ autoBind(this);
+
+ this.revisionStore = new RevisionStore()
+ const params = new URLSearchParams(window.location.search.substring(1))
+ let port = params.get("localport")
+ if (port == null && (window.location.port != 80 && window.location.port != 443)) {
+ port = window.location.port
+ }
+ this.styleStore = new ApiStyleStore({
+ onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
+ port: port,
+ host: params.get("localhost")
+ })
+
+
+ const shortcuts = [
+ {
+ key: "j",
+ handler: () => {
+ this.toggleModal("json_editor");
+ }
+ },
+ {
+ key: "?",
+ handler: () => {
+ this.toggleModal("shortcuts");
+ }
+ },
+ {
+ key: "o",
+ handler: () => {
+ this.toggleModal("open");
+ }
+ },
+ {
+ key: "e",
+ handler: () => {
+ this.toggleModal("export");
+ }
+ },
+ {
+ key: "d",
+ handler: () => {
+ this.toggleModal("sources");
+ }
+ },
+ {
+ key: "s",
+ handler: () => {
+ this.toggleModal("settings");
+ }
+ },
+ {
+ key: "i",
+ handler: () => {
+ this.setMapState(
+ this.state.mapState === "map" ? "inspect" : "map"
+ );
+ }
+ },
+ {
+ key: "m",
+ handler: () => {
+ document.querySelector(".mapboxgl-canvas").focus();
+ }
+ },
+ {
+ key: "!",
+ handler: () => {
+ this.toggleModal("debug");
+ }
+ },
+ ]
+
+ document.body.addEventListener("keyup", (e) => {
+ if(e.key === "Escape") {
+ e.target.blur();
+ document.body.focus();
+ }
+ else if(this.state.isOpen.shortcuts || document.activeElement === document.body) {
+ const shortcut = shortcuts.find((shortcut) => {
+ return (shortcut.key === e.key)
+ })
+
+ if(shortcut) {
+ this.setModal("shortcuts", false);
+ shortcut.handler(e);
+ }
+ }
+ })
+
+ const styleUrl = initialStyleUrl()
+ if(styleUrl && window.confirm("Load style from URL: " + styleUrl + " and discard current changes?")) {
+ this.styleStore = new StyleStore()
+ loadStyleUrl(styleUrl, mapStyle => this.onStyleChanged(mapStyle))
+ removeStyleQuerystring()
+ } else {
+ if(styleUrl) {
+ removeStyleQuerystring()
+ }
+ this.styleStore.init(err => {
+ if(err) {
+ console.log('Falling back to local storage for storing styles')
+ this.styleStore = new StyleStore()
+ }
+ this.styleStore.latestStyle(mapStyle => this.onStyleChanged(mapStyle))
+
+ if(Debug.enabled()) {
+ Debug.set("maputnik", "styleStore", this.styleStore);
+ Debug.set("maputnik", "revisionStore", this.revisionStore);
+ }
+ })
+ }
+
+ if(Debug.enabled()) {
+ Debug.set("maputnik", "revisionStore", this.revisionStore);
+ Debug.set("maputnik", "styleStore", this.styleStore);
+ }
+
+ const queryObj = url.parse(window.location.href, true).query;
+
+ this.state = {
+ errors: [],
+ infos: [],
+ mapStyle: style.emptyStyle,
+ selectedLayerIndex: 0,
+ sources: {},
+ vectorLayers: {},
+ mapState: "map",
+ spec: latest,
+ mapView: {
+ zoom: 0,
+ center: {
+ lng: 0,
+ lat: 0,
+ },
+ },
+ isOpen: {
+ settings: false,
+ sources: false,
+ open: false,
+ shortcuts: false,
+ export: false,
+ // TODO: Disabled for now, this should be opened on the Nth visit to the editor
+ survey: false,
+ debug: false,
+ },
+ mapboxGlDebugOptions: {
+ showTileBoundaries: false,
+ showCollisionBoxes: false,
+ showOverdrawInspector: false,
+ },
+ openlayersDebugOptions: {
+ debugToolbox: false,
+ },
+ }
+
+ this.layerWatcher = new LayerWatcher({
+ onVectorLayersChange: v => this.setState({ vectorLayers: v })
+ })
+ }
+
+ handleKeyPress = (e) => {
+ if(navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
+ if(e.metaKey && e.shiftKey && e.keyCode === 90) {
+ e.preventDefault();
+ this.onRedo(e);
+ }
+ else if(e.metaKey && e.keyCode === 90) {
+ e.preventDefault();
+ this.onUndo(e);
+ }
+ }
+ else {
+ if(e.ctrlKey && e.keyCode === 90) {
+ e.preventDefault();
+ this.onUndo(e);
+ }
+ else if(e.ctrlKey && e.keyCode === 89) {
+ e.preventDefault();
+ this.onRedo(e);
+ }
+ }
+ }
+
+ componentDidMount() {
+ window.addEventListener("keydown", this.handleKeyPress);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("keydown", this.handleKeyPress);
+ }
+
+ saveStyle(snapshotStyle) {
+ this.styleStore.save(snapshotStyle)
+ }
+
+ updateFonts(urlTemplate) {
+ const metadata = this.state.mapStyle.metadata || {}
+ const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles
+
+ let glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate;
+ downloadGlyphsMetadata(glyphUrl, fonts => {
+ this.setState({ spec: updateRootSpec(this.state.spec, 'glyphs', fonts)})
+ })
+ }
+
+ updateIcons(baseUrl) {
+ downloadSpriteMetadata(baseUrl, icons => {
+ this.setState({ spec: updateRootSpec(this.state.spec, 'sprite', icons)})
+ })
+ }
+
+ onChangeMetadataProperty = (property, value) => {
+ // If we're changing renderer reset the map state.
+ if (
+ property === 'maputnik:renderer' &&
+ value !== get(this.state.mapStyle, ['metadata', 'maputnik:renderer'], 'mbgljs')
+ ) {
+ this.setState({
+ mapState: 'map'
+ });
+ }
+
+ const changedStyle = {
+ ...this.state.mapStyle,
+ metadata: {
+ ...this.state.mapStyle.metadata,
+ [property]: value
+ }
+ }
+ this.onStyleChanged(changedStyle)
+ }
+
+ onStyleChanged = (newStyle, opts={}) => {
+ opts = {
+ save: true,
+ addRevision: true,
+ ...opts,
+ };
+
+ const errors = validate(newStyle, latest) || [];
+
+ // The validate function doesn't give us errors for duplicate error with
+ // empty string for layer.id, manually deal with that here.
+ const layerErrors = [];
+ if (newStyle && newStyle.layers) {
+ const foundLayers = new Map();
+ newStyle.layers.forEach((layer, index) => {
+ if (layer.id === "" && foundLayers.has(layer.id)) {
+ const message = `Duplicate layer: [empty string]`;
+ layerErrors.push({
+ message,
+ parsed: {
+ type: "layer",
+ data: {
+ index,
+ message,
+ }
+ }
+ });
+ }
+ foundLayers.set(layer.id, true);
+ });
+ }
+
+ 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,
+ };
+ }
+ }).concat(layerErrors);
+
+ 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.onStyleChanged(activeStyle, {addRevision: false});
+ this.setState({
+ infos: messages,
+ })
+ }
+
+ onRedo = () => {
+ const activeStyle = this.revisionStore.redo()
+ const messages = redoMessages(this.state.mapStyle, activeStyle)
+ this.onStyleChanged(activeStyle, {addRevision: false});
+ this.setState({
+ infos: messages,
+ })
+ }
+
+ onMoveLayer = (move) => {
+ let { oldIndex, newIndex } = move;
+ let layers = this.state.mapStyle.layers;
+ oldIndex = clamp(oldIndex, 0, layers.length-1);
+ newIndex = clamp(newIndex, 0, layers.length-1);
+ if(oldIndex === newIndex) return;
+
+ if (oldIndex === this.state.selectedLayerIndex) {
+ this.setState({
+ selectedLayerIndex: newIndex
+ });
+ }
+
+ layers = layers.slice(0);
+ layers = arrayMove(layers, oldIndex, newIndex);
+ this.onLayersChange(layers);
+ }
+
+ onLayersChange = (changedLayers) => {
+ const changedStyle = {
+ ...this.state.mapStyle,
+ layers: changedLayers
+ }
+ this.onStyleChanged(changedStyle)
+ }
+
+ onLayerDestroy = (index) => {
+ let layers = this.state.mapStyle.layers;
+ const remainingLayers = layers.slice(0);
+ remainingLayers.splice(index, 1);
+ this.onLayersChange(remainingLayers);
+ }
+
+ onLayerCopy = (index) => {
+ let layers = this.state.mapStyle.layers;
+ const changedLayers = layers.slice(0)
+
+ const clonedLayer = cloneDeep(changedLayers[index])
+ clonedLayer.id = clonedLayer.id + "-copy"
+ changedLayers.splice(index, 0, clonedLayer)
+ this.onLayersChange(changedLayers)
+ }
+
+ onLayerVisibilityToggle = (index) => {
+ let layers = this.state.mapStyle.layers;
+ const changedLayers = layers.slice(0)
+
+ const layer = { ...changedLayers[index] }
+ const changedLayout = 'layout' in layer ? {...layer.layout} : {}
+ changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none'
+
+ layer.layout = changedLayout
+ changedLayers[index] = layer
+ this.onLayersChange(changedLayers)
+ }
+
+
+ onLayerIdChange = (index, oldId, newId) => {
+ const changedLayers = this.state.mapStyle.layers.slice(0)
+ changedLayers[index] = {
+ ...changedLayers[index],
+ id: newId
+ }
+
+ this.onLayersChange(changedLayers)
+ }
+
+ onLayerChanged = (index, layer) => {
+ const changedLayers = this.state.mapStyle.layers.slice(0)
+ changedLayers[index] = layer
+
+ this.onLayersChange(changedLayers)
+ }
+
+ setMapState = (newState) => {
+ this.setState({
+ mapState: newState
+ })
+ }
+
+ setDefaultValues = (styleObj) => {
+ const metadata = styleObj.metadata || {}
+ if(metadata['maputnik:renderer'] === undefined) {
+ const changedStyle = {
+ ...styleObj,
+ metadata: {
+ ...styleObj.metadata,
+ 'maputnik:renderer': 'mbgljs'
+ }
+ }
+ return changedStyle
+ } else {
+ return styleObj
+ }
+ }
+
+ openStyle = (styleObj) => {
+ styleObj = this.setDefaultValues(styleObj)
+ this.onStyleChanged(styleObj)
+ }
+
+ fetchSources() {
+ const sourceList = {...this.state.sources};
+
+ for(let [key, val] of Object.entries(this.state.mapStyle.sources)) {
+ if(sourceList.hasOwnProperty(key)) {
+ continue;
+ }
+
+ sourceList[key] = {
+ type: val.type,
+ layers: []
+ };
+
+ if(!this.state.sources.hasOwnProperty(key) && val.type === "vector" && val.hasOwnProperty("url")) {
+ let url = val.url;
+ try {
+ url = normalizeSourceURL(url, MapboxGl.accessToken);
+ } catch(err) {
+ console.warn("Failed to normalizeSourceURL: ", err);
+ }
+
+ try {
+ url = setFetchAccessToken(url, this.state.mapStyle)
+ } catch(err) {
+ console.warn("Failed to setFetchAccessToken: ", err);
+ }
+
+ fetch(url, {
+ mode: 'cors',
+ })
+ .then((response) => {
+ return response.json();
+ })
+ .then((json) => {
+ if(!json.hasOwnProperty("vector_layers")) {
+ return;
+ }
+
+ // Create new objects before setState
+ const sources = Object.assign({}, this.state.sources);
+
+ for(let layer of json.vector_layers) {
+ sources[key].layers.push(layer.id)
+ }
+
+ console.debug("Updating source: "+key);
+ this.setState({
+ sources: sources
+ });
+ })
+ .catch((err) => {
+ console.error("Failed to process sources for '%s'", url, err);
+ })
+ }
+ }
+
+ if(!isEqual(this.state.sources, sourceList)) {
+ console.debug("Setting sources");
+ this.setState({
+ sources: sourceList
+ })
+ }
+ }
+
+ _getRenderer () {
+ const metadata = this.state.mapStyle.metadata || {};
+ return metadata['maputnik:renderer'] || 'mbgljs';
+ }
+
+ onMapChange = (mapView) => {
+ this.setState({
+ mapView,
+ });
+ }
+
+ mapRenderer() {
+ const {mapStyle, dirtyMapStyle} = this.state;
+ const metadata = this.state.mapStyle.metadata || {};
+
+ const mapProps = {
+ mapStyle: (dirtyMapStyle || mapStyle),
+ replaceAccessTokens: (mapStyle) => {
+ return style.replaceAccessTokens(mapStyle, {
+ allowFallback: true
+ });
+ },
+ onDataChange: (e) => {
+ this.layerWatcher.analyzeMap(e.map)
+ this.fetchSources();
+ },
+ }
+
+ const renderer = this._getRenderer();
+
+ let mapElement;
+
+ // Check if OL code has been loaded?
+ if(renderer === 'ol') {
+ mapElement =
+ } else {
+ mapElement =
+ }
+
+ let filterName;
+ if(this.state.mapState.match(/^filter-/)) {
+ filterName = this.state.mapState.replace(/^filter-/, "");
+ }
+ const elementStyle = {};
+ if (filterName) {
+ elementStyle.filter = `url('#${filterName}')`;
+ };
+
+ return
+ {mapElement}
+
+ }
+
+ onLayerSelect = (index) => {
+ this.setState({ selectedLayerIndex: index })
+ }
+
+ setModal(modalName, value) {
+ if(modalName === 'survey' && value === false) {
+ localStorage.setItem('survey', '');
+ }
+
+ this.setState({
+ isOpen: {
+ ...this.state.isOpen,
+ [modalName]: value
+ }
+ })
+ }
+
+ toggleModal(modalName) {
+ this.setModal(modalName, !this.state.isOpen[modalName]);
+ }
+
+ onChangeOpenlayersDebug = (key, value) => {
+ this.setState({
+ openlayersDebugOptions: {
+ ...this.state.openlayersDebugOptions,
+ [key]: value,
+ }
+ });
+ }
+
+ onChangeMaboxGlDebug = (key, value) => {
+ this.setState({
+ mapboxGlDebugOptions: {
+ ...this.state.mapboxGlDebugOptions,
+ [key]: value,
+ }
+ });
+ }
+
+ render() {
+ const layers = this.state.mapStyle.layers || []
+ const selectedLayer = layers.length > 0 ? layers[this.state.selectedLayerIndex] : null
+ const metadata = this.state.mapStyle.metadata || {}
+
+ const toolbar =
+
+ const layerList =
+
+ const layerEditor = selectedLayer ? : null
+
+ const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? : null
+
+
+ const modals =
+
+ this.shortcutEl = el}
+ isOpen={this.state.isOpen.shortcuts}
+ onOpenToggle={this.toggleModal.bind(this, 'shortcuts')}
+ />
+
+
+
+
+
+
+
+
+ return
+ }
+}
diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx
index c2c25c17..3dbcab56 100644
--- a/src/components/Toolbar.jsx
+++ b/src/components/Toolbar.jsx
@@ -185,18 +185,21 @@ export default class Toolbar extends React.Component {
>
{/* Keyboard accessible quick links */}