From 82abfeb8ff83e40f3efbaaaebc9be76653b54b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Thu, 16 May 2024 13:50:07 +0200 Subject: [PATCH] feat(sync) Add a `belongsTo` property in the schema This new property allows to know what's the relation with the schema property and the different object types (map, datalayer, feature). This serves two goals: 1. Check that the incoming messages make sense (it's possible to change this property in this object) 2. Document where the property is used in practice. Because there is a lot of properties, it's hard to keep track of where they are used. This makes it explicit. --- umap/static/umap/js/modules/schema.js | 75 +++++++++++++++++++ umap/static/umap/js/modules/sync/updaters.js | 11 ++- umap/static/umap/js/modules/utils.js | 25 +++++++ umap/static/umap/unittests/utils.js | 78 ++++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index b7f5080f..fe21f78b 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -11,6 +11,8 @@ import { translate } from './i18n.js' * - `impacts`: A list of impacts than happen when this property is updated, among * 'ui', 'data', 'limit-bounds', 'datalayer-index', 'remote-data', * 'background' 'sync'. + * - `belongsTo`: A list of conceptual objects this property belongs to, among + * 'map', 'feature', 'datalayer'. * * - Extra keys are being passed to the FormBuilder automatically. */ @@ -20,16 +22,19 @@ export const SCHEMA = { browsable: { type: Boolean, impacts: ['ui'], + belongsTo: ['datalayer'], }, captionBar: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display a caption bar?'), default: false, }, captionControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the caption control'), default: true, @@ -37,12 +42,14 @@ export const SCHEMA = { captionMenus: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display caption menus?'), default: true, }, color: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'ColorPicker', label: translate('color'), helpEntries: 'colorValue', @@ -52,14 +59,17 @@ export const SCHEMA = { choropleth: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, cluster: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, dashArray: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('dash array'), helpEntries: 'dashArray', inheritable: true, @@ -67,6 +77,7 @@ export const SCHEMA = { datalayersControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, handler: 'DataLayersControl', label: translate('Display the data layers control'), @@ -75,6 +86,7 @@ export const SCHEMA = { defaultView: { type: String, impacts: [], // no need to update the ui, only useful when loading the map + belongsTo: ['map'], label: translate('Default view'), choices: [ ['center', translate('Saved center and zoom')], @@ -87,27 +99,32 @@ export const SCHEMA = { description: { type: 'Text', impacts: ['ui'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('description'), helpEntries: 'textFormatting', }, displayOnLoad: { type: Boolean, impacts: [], + belongsTo: ['datalayer'], }, displayPopupFooter: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display popup footer?'), default: false, }, easing: { type: Boolean, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], default: false, }, editinosmControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the control to open OpenStreetMap editor'), default: null, @@ -115,6 +132,7 @@ export const SCHEMA = { embedControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the embed control'), default: true, @@ -122,10 +140,12 @@ export const SCHEMA = { facetKey: { type: String, impacts: ['ui'], + belongsTo: ['map', 'datalayer'], }, fill: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('fill'), helpEntries: 'fill', inheritable: true, @@ -134,6 +154,7 @@ export const SCHEMA = { fillColor: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'ColorPicker', label: translate('fill color'), helpEntries: 'fillColor', @@ -142,6 +163,7 @@ export const SCHEMA = { fillOpacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -152,16 +174,19 @@ export const SCHEMA = { filterKey: { type: String, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], }, fromZoom: { type: Number, impacts: [], // not needed + belongsTo: ['map', 'datalayer'], label: translate('From zoom'), helpText: translate('Optional.'), }, fullscreenControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the fullscreen control'), default: true, @@ -169,14 +194,17 @@ export const SCHEMA = { geometry: { type: Object, impacts: ['data'], + belongsTo: ['feature'], }, heat: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, iconClass: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Icon shape'), inheritable: true, choices: [ @@ -190,6 +218,7 @@ export const SCHEMA = { iconOpacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -200,6 +229,7 @@ export const SCHEMA = { iconUrl: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'IconUrl', label: translate('Icon symbol'), inheritable: true, @@ -207,10 +237,12 @@ export const SCHEMA = { inCaption: { type: Boolean, impacts: ['ui'], + belongsTo: ['datalayer'], }, interactive: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Allow interactions'), helpEntries: 'interactive', inheritable: true, @@ -219,6 +251,7 @@ export const SCHEMA = { labelDirection: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Label direction'), inheritable: true, choices: [ @@ -233,12 +266,14 @@ export const SCHEMA = { labelInteractive: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Labels are clickable'), inheritable: true, }, labelKey: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], helpEntries: 'labelKey', placeholder: translate('Default: name'), label: translate('Label key'), @@ -247,50 +282,59 @@ export const SCHEMA = { licence: { type: String, impacts: ['ui'], + belongsTo: ['map', 'datalayer'], label: translate('licence'), }, limitBounds: { type: Object, impacts: ['limit-bounds'], + belongsTo: ['map'], }, locateControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the locate control'), }, longCredit: { type: 'Text', impacts: ['ui'], + belongsTo: ['map'], label: translate('Long credits'), helpEntries: ['longCredit', 'textFormatting'], }, measureControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the measure control'), }, miniMap: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display a minimap?'), default: false, }, moreControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display the «more» control?'), default: true, }, name: { type: String, impacts: ['ui', 'data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('name'), }, onLoadPanel: { type: String, impacts: [], // This is what happens during the map instantiation + belongsTo: ['map'], label: translate('Do you want to display a panel on load?'), choices: [ ['none', translate('None')], @@ -304,6 +348,7 @@ export const SCHEMA = { opacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -314,6 +359,7 @@ export const SCHEMA = { outlink: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Link to…'), helpEntries: 'outlink', placeholder: 'http://...', @@ -322,6 +368,7 @@ export const SCHEMA = { outlinkTarget: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Open link in…'), inheritable: true, default: 'blank', @@ -334,22 +381,26 @@ export const SCHEMA = { overlay: { type: Object, impacts: ['background'], + belongsTo: ['map'], }, permanentCredit: { type: 'Text', impacts: ['ui'], + belongsTo: ['map'], label: translate('Permanent credits'), helpEntries: ['permanentCredit', 'textFormatting'], }, permanentCreditBackground: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Permanent credits background'), default: true, }, popupContentTemplate: { type: 'Text', impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup content template'), helpEntries: ['dynamicProperties', 'textFormatting'], placeholder: '# {name}', @@ -359,6 +410,7 @@ export const SCHEMA = { popupShape: { type: String, impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup shape'), inheritable: true, choices: [ @@ -371,6 +423,7 @@ export const SCHEMA = { popupTemplate: { type: String, impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup content style'), inheritable: true, choices: [ @@ -385,21 +438,25 @@ export const SCHEMA = { remoteData: { type: Object, impacts: ['remote-data'], + belongsTo: ['datalayer'], }, scaleControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display the scale control?'), default: true, }, scrollWheelZoom: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Allow scroll wheel zoom?'), }, searchControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the search control'), default: true, @@ -407,28 +464,33 @@ export const SCHEMA = { shortCredit: { type: String, impacts: ['ui'], + belongsTo: ['map'], label: translate('Short credits'), helpEntries: ['shortCredit', 'textFormatting'], }, showLabel: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], nullable: true, label: translate('Display label'), inheritable: true, default: false, }, slideshow: { + belongsTo: ['map'], type: Object, impacts: ['ui'], }, slugKey: { type: String, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], }, smoothFactor: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0, max: 10, step: 0.5, @@ -440,16 +502,19 @@ export const SCHEMA = { sortKey: { type: String, impacts: ['datalayer-index', 'data'], + belongsTo: ['map', 'datalayer'], }, starControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the star map button'), }, stroke: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('stroke'), helpEntries: 'stroke', inheritable: true, @@ -458,6 +523,7 @@ export const SCHEMA = { syncEnabled: { type: Boolean, impacts: ['sync', 'ui'], + belongsTo: ['map'], label: translate('Enable real-time collaboration'), helpEntries: 'sync', default: false, @@ -465,26 +531,31 @@ export const SCHEMA = { tilelayer: { type: Object, impacts: ['background'], + belongsTo: ['map'], }, tilelayersControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the tile layers control'), }, toZoom: { type: Number, impacts: [], // not needed + belongsTo: ['map', 'datalayer'], label: translate('To zoom'), helpText: translate('Optional.'), }, type: { type: 'String', impacts: ['data'], + belongsTo: ['datalayer'], }, weight: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 1, max: 20, step: 1, @@ -495,10 +566,12 @@ export const SCHEMA = { zoom: { type: Number, impacts: [], // default zoom, doesn't need to be updated + belongsTo: ['map'], }, zoomControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the zoom control'), default: true, @@ -506,6 +579,7 @@ export const SCHEMA = { zoomTo: { type: Number, impacts: [], // not need to update the view + belongsTo: ['map', 'datalayer', 'feature'], placeholder: translate('Inherit'), helpEntries: 'zoomTo', label: translate('Default zoom level'), @@ -514,5 +588,6 @@ export const SCHEMA = { _latlng: { type: Object, impacts: ['data'], + belongsTo: ['feature'], }, } diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js index b3c5abe1..29c1c995 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -1,3 +1,5 @@ +import { propertyBelongsTo } from '../utils.js' + /** * This file contains the updaters: classes that are able to convert messages * received from another party (or the server) to changes on the map. @@ -35,7 +37,14 @@ class BaseUpdater { } applyMessage(payload) { - let { verb } = payload + let { verb, subject } = payload + + if (verb == 'update') { + if (!propertyBelongsTo(payload.key, subject)) { + console.error('Invalid message received', payload) + return // Do not apply the message + } + } return this[verb](payload) } } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index a7643533..831196c1 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -54,6 +54,31 @@ export function getImpactsFromSchema(fields, schema) { return Array.from(impacted) } +/** + * Checks the given property belongs to the given subject, according to the schema. + * + * @param srtring property + * @param string subject + * @param object schema object. If ommited, global U.SCHEMA will be used. + * @returns Bool + */ +export function propertyBelongsTo(property, subject, schema) { + schema = schema || U.SCHEMA + if (subject === 'feature') { + // FIXME allow properties.whatever + property = property.replace('properties.', '').replace('_umap_options.', '') + } + property = property.replace('options.', '') + const splits = property.split('.') + const nested = splits.length > 1 + if (nested) property = splits[0] + if (!Object.keys(schema).includes(property)) return false + if (nested) { + if (schema[property].type !== Object) return false + } + return schema[property].belongsTo.includes(subject) +} + /** * Import DOM purify, and initialize it. * diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index 108d73ae..8c18fc1b 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -597,6 +597,84 @@ describe('Utils', function () { }) }) + describe('#propertyBelongsTo', () => { + it('should return false on unexisting property', function () { + let schema = {} + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), false) + }) + it('should return false if subject is not listed', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), false) + }) + it('should return true if subject is listed', function () { + let schema = { + foo: { belongsTo: ['map'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), true) + }) + it('should remove the `options.` prefix before checking', function () { + let schema = { + foo: { belongsTo: ['map'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('options.foo', 'map', schema), true) + }) + + it('Accepts setting properties on objects', function () { + let schema = { + foo: { + type: Object, + belongsTo: ['map'], + }, + } + assert.deepEqual(Utils.propertyBelongsTo('options.foo.name', 'map', schema), true) + }) + + it('Rejects setting properties on non-objects', function () { + let schema = { + foo: { + type: String, + belongsTo: ['map'], + }, + } + assert.deepEqual( + Utils.propertyBelongsTo('options.foo.name', 'map', schema), + false + ) + }) + + it('when subject = feature, should filter the `properties.`', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('properties.foo', 'feature', schema), + true + ) + }) + + it('On features, should filter the `_umap_options.`', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('properties._umap_options.foo', 'feature', schema), + true + ) + }) + + it('Should accept options.tilelayer.url_template', function () { + let schema = { + tilelayer: { type: Object, belongsTo: ['map'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('options.tilelayer.url_template', 'map', schema), + true + ) + }) + }) + describe('#parseNaiveDate', () => { it('should parse a date', () => { assert.equal(