diff --git a/src/css/map-view.css b/src/css/map-view.css index 41f6ff14d3..4976216395 100644 --- a/src/css/map-view.css +++ b/src/css/map-view.css @@ -2808,3 +2808,12 @@ other class: .ui-slider-range */ ); } } + +/* Map Editor View */ +.map-editor { + font-family: var(--map-body-font); + display: grid; + position: relative; + box-sizing: border-box; + background-color: var(--map-col-background); +} diff --git a/src/js/common/XMLUtilities.js b/src/js/common/XMLUtilities.js index 3bf2fedccf..af40fe8fa6 100644 --- a/src/js/common/XMLUtilities.js +++ b/src/js/common/XMLUtilities.js @@ -661,6 +661,29 @@ define([], () => { return element; }, + /** + * Append a child element containing CDATA. Split `]]>` across CDATA nodes + * because it cannot appear within a single CDATA section. + * @param {XMLDocument} doc XML document used to create elements. + * @param {Element} parent Parent element that receives the child. + * @param {string} name Child element name. + * @param {string} value Text value to append. + * @returns {Element} The appended child element. + */ + appendCdataElement(doc, parent, name, value) { + const element = doc.createElement(name); + const text = this.removeInvalidXmlCharacters(value); + const parts = text.split("]]>"); + + parts.forEach((part, index) => { + const content = `${index ? ">" : ""}${part}${index < parts.length - 1 ? "]]" : ""}`; + element.appendChild(doc.createCDATASection(content)); + }); + + parent.appendChild(element); + return element; + }, + /** * Extract the XML declaration from raw XML text, if present. * @param {string|null|undefined} xmlString XML text to inspect. diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 3b2164ea4a..e9ac253acf 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -466,6 +466,10 @@ define([ */ initialize(options = {}) { const config = options; + + // Keep a copy of the original configuration for reference. + this.set("originalConfig", JSON.stringify(config)); + if (config && config instanceof Object) { const visibilityState = parseLayerVisibilityStateFromUrl(); if (isNonEmptyArray(config.layerCategories)) { @@ -1133,6 +1137,36 @@ define([ }); this.refreshAllLayers(); }, + + /** + * Validate the map model. + * @returns {object|null} Null if map is valid, otherwise errors keyed by + * attribute names. + */ + validate() { + // TODO: Minimum required validation logic. + return null; + }, + + /** @returns {boolean} True if map is valid, otherwise false. */ + isValid() { + return this.validate() === null; + }, + + /** + * Serialize the map model to JSON for saving, for example, into a portal + * document. + * @returns {string} The JSON representation of the map model as a string. + * @since 0.0.0 + */ + toConfig() { + // TODO: Build toConfig to convert map model abd sub models to a JSON + // representation suitable for saving. + + // As an intermediate step in the iterative development, return the + // original, unchanged config + return JSON.parse(this.get("originalConfig")); + }, }, ); diff --git a/src/js/models/portals/PortalModel.js b/src/js/models/portals/PortalModel.js index 67466e672d..8667afb0f4 100644 --- a/src/js/models/portals/PortalModel.js +++ b/src/js/models/portals/PortalModel.js @@ -2,12 +2,12 @@ define([ "jquery", "underscore", - "backbone", "gmaps", "common/ValueUtilities", "models/filters/Filter", "models/portals/PortalSectionModel", "models/portals/PortalVizSectionModel", + "models/portals/PortalOption", "models/portals/PortalImage", "models/metadata/eml211/EMLParty", "models/metadata/eml220/EMLText", @@ -18,12 +18,12 @@ define([ ], ( $, _, - Backbone, gmaps, ValueUtilities, FilterModel, PortalSectionModel, PortalVizSectionModel, + PortalOption, PortalImage, EMLParty, EMLText, @@ -410,30 +410,36 @@ define([ } } - let requestSettings = { + // Record whether this fetch uses authentication for private portals. + this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn")); + + const requestSettings = { dataType: "xml", - error(model, response) { - model.trigger("error", model, response); + success: (response) => { + let attributes; + try { + attributes = this.parse(response); + } catch (error) { + this.trigger("error", this, error.message); + return; + } - if (response && response.status === 404) { - model.trigger("notFound"); + this.set(attributes); + this.trigger("sync", this, response, requestSettings); + }, + error: (response) => { + this.trigger("error", this, response); + + if (response?.status === 404) { + this.trigger("notFound"); } }, + ...MetacatUI.appUserModel.createAjaxSettings(), }; - // Save a boolean flag for whether or not this fetch was done with user - // authentication. This is helpful when the app is dealing with - // potentially private data - this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn")); - - // Add the user settings to the fetch settings - requestSettings = _.extend( - requestSettings, - MetacatUI.appUserModel.createAjaxSettings(), - ); - - // Call Backbone.Model.fetch() - return Backbone.Model.prototype.fetch.call(this, requestSettings); + // Parse before setting attributes or emitting sync so a failed load + // cannot open the editor with incomplete data. + return this.sync("read", this, requestSettings); }, /** @@ -617,19 +623,13 @@ define([ .children("section") .each((i, section) => { // Get the section type, if there is one - const sectionTypeNode = $(section).find( - "optionName:contains(sectionType)", + const sectionTypeOption = PortalOption.findDirectChild( + section, + "sectionType", ); - let sectionType = ""; - - if (sectionTypeNode.length) { - const optionValueNode = sectionTypeNode - .first() - .siblings("optionValue"); - if (optionValueNode.length) { - sectionType = optionValueNode[0].textContent; - } - } + const sectionType = sectionTypeOption + ? PortalOption.fromElement(sectionTypeOption).optionValue[0] + : ""; if (sectionType === "visualization") { // Create a new PortalVizSectionModel @@ -700,14 +700,13 @@ define([ ); }); - // Parse the options. Use children() and not find() because we only want - // option nodes that are direct children of the portal node. Option - // nodes can also be found within section nodes. - $(portalNode) - .children("option") - .each((i, option) => { - const optionName = $(option).find("optionName")[0].textContent; - let optionValue = $(option).find("optionValue")[0].textContent; + // Parse only direct root options; section options are nested in sections. + Array.from(portalNode.children) + .filter((child) => child.localName === "option") + .forEach((element) => { + const option = PortalOption.fromElement(element); + const { optionName } = option; + let optionValue = option.optionValue[0]; if (optionValue === "true") { optionValue = true; @@ -715,10 +714,6 @@ define([ optionValue = false; } - // TODO: keep a list of optionNames so that in the case of custom - // options, we can serialize them in serialize() otherwise it's not - // saved in the model which attributes are s - // Convert the comma separated list of pages into an array if ( optionName === "pageOrder" && @@ -1278,78 +1273,47 @@ define([ }); } - try { - /* ==== Serialize options (including map options) ==== */ - // This will only serialize the options named in `optNames` (below) - // Functionality needed in order to serialize new or custom options - - // The standard list of options used in portals - const optNames = this.get("optionNames"); - - _.each(optNames, (optName) => { - // Get the value on the model - const optValue = model.get(optName); - - // Get the existing optionName element - let matchingOption = $portalNode - .children("option") - .find(`optionName:contains('${optName}')`); + /* ==== Serialize options (including map options) ==== */ + // Only the standard option names are written from model attributes. + // Existing custom options remain in the cloned XML, but new or edited + // custom option attributes are not saved until their names are tracked. + // TODO: Track custom option names if editing them is supported. + _.each(this.get("optionNames"), (optName) => { + const optValue = model.get(optName); + const matchingOption = PortalOption.findDirectChild( + portalNode, + optName, + ); - // - if ( - !matchingOption.length || - matchingOption.first().text() !== optName - ) { - matchingOption = false; - } + if ( + (optValue || optValue === 0 || optValue === false) && + // XML option values are strings, while model defaults can be numbers. + // eslint-disable-next-line eqeqeq + optValue != model.defaults()[optName] + ) { + const option = matchingOption + ? PortalOption.fromElement(matchingOption) + : new PortalOption({ + optionName: optName, + optionValue: [String(optValue)], + }); + if (matchingOption) option.optionValue[0] = String(optValue); + const optionElement = option.toElement(xmlDoc); - // Don't serialize null or undefined values. Also don't serialize - // values that match the default model value - if ( - (optValue || optValue === 0 || optValue === false) && - // XML option values are strings, while model defaults can be numbers. - // eslint-disable-next-line eqeqeq - optValue != model.defaults()[optName] - ) { - // Replace the existing option, if it exists - if (matchingOption) { - matchingOption.siblings("optionValue").text(optValue); + if (matchingOption) { + matchingOption.replaceWith(optionElement); + } else { + const insertAfter = model.getXMLPosition(portalNode, "option"); + if (insertAfter) { + insertAfter.after(optionElement); } else { - // Make new node and are - // subelements of + + `, @@ -137,6 +175,11 @@ define([ expect(getOptionValue(updatedDOM, "visualizationType")).to.equal( "cesium", ); + expect( + PortalOption.fromElement( + PortalOption.findDirectChild(updatedDOM, "visualizationType"), + ).optionValue, + ).to.deep.equal(["cesium", "keep visualization extra"]); const updatedMapConfig = JSON.parse( getOptionValue(updatedDOM, "mapConfig"), ); @@ -144,6 +187,17 @@ define([ mapConfig.homePosition, ); expect(updatedMapConfig.showToolbar).to.equal(false); + const mapOptions = Array.from(updatedDOM.children).filter( + (child) => + child.localName === "option" && + child.firstElementChild?.textContent === "mapConfig", + ); + expect(mapOptions).to.have.length(1); + expect(mapOptions[0].children[1].firstChild.nodeType).to.equal(4); + expect(PortalOption.fromElement(mapOptions[0]).optionValue[1]).to.equal( + "keep map extra", + ); + expect(getOptionValue(updatedDOM, "mapConfigExtra")).to.equal("leave me"); }); it("adds and removes a visualization section instance", () => { @@ -159,15 +213,69 @@ define([ expect(portal.get("sections")[0]).to.equal(sectionToKeep); }); + it("creates a Cesium section by type", () => { + const portal = new PortalModel({}); + const section = portal.addSection("cesium"); + + expect(section).to.be.instanceof(PortalVizSectionModel); + expect(section.get("visualizationType")).to.equal("cesium"); + expect(section.get("mapModel")).to.be.instanceof(Map); + expect(portal.sectionIsDefault(section)).to.equal(false); + + section.set("label", "Map"); + const sectionDOM = section.updateDOM(); + expect(sectionDOM.getElementsByTagName("label")[0].textContent).to.equal( + "Map", + ); + expect(sectionDOM.firstElementChild.tagName).to.equal("label"); + expect(getOptionValue(sectionDOM, "visualizationType")).to.equal( + "cesium", + ); + const mapConfig = PortalOption.findDirectChild(sectionDOM, "mapConfig"); + expect(mapConfig).to.exist; + expect(mapConfig.children[1].firstChild.nodeType).to.equal(4); + expect(JSON.parse(mapConfig.children[1].textContent)).to.deep.equal({}); + }); + it("round-trips freeform and Cesium sections through serialization", () => { const portal = createMixedPortal(); + portal.set("hideData", false); + portal.set("theme", "ocean"); const serializedPortal = portal.serialize(); expect(serializedPortal).to.be.a("string").and.not.empty; + const serializedXML = new DOMParser().parseFromString( + serializedPortal, + "application/xml", + ); + const root = serializedXML.documentElement; + const mapSectionElement = Array.from(root.children).find( + (child) => + child.localName === "section" && + child.firstElementChild?.textContent === "Map", + ); + const mapOption = PortalOption.findDirectChild( + mapSectionElement, + "mapConfig", + ); + + expect(root.namespaceURI).to.equal( + "https://purl.dataone.org/portals-1.1.0", + ); + expect(mapOption.children[1].firstChild.nodeType).to.equal(4); + expect( + JSON.parse(mapOption.children[1].textContent).showToolbar, + ).to.equal(false); + expect( + PortalOption.findDirectChild(root, "customRoot").children[1] + .textContent, + ).to.equal("keep root"); const reloadedPortal = parsePortal(serializedPortal); const [freeformSection, mapSection] = reloadedPortal.get("sections"); + expect(reloadedPortal.get("hideData")).to.equal(false); + expect(reloadedPortal.get("theme")).to.equal("ocean"); expect(reloadedPortal.get("sections")).to.have.length(2); expect( reloadedPortal.get("sections").map((section) => section.get("label")), @@ -187,6 +295,47 @@ define([ expect(mapSection.get("mapModel").get("showToolbar")).to.equal(false); }); + it("serializes and reparses a new portal with root and Cesium options", () => { + const portal = new PortalModel({}); + portal.set("label", "New portal"); + portal.set("hideMetrics", true); + portal.get("definitionFilters").add({ + fields: ["formatType"], + values: ["METADATA"], + }); + const section = portal.addSection("cesium"); + section.set("label", "Map"); + + const serialized = portal.serialize(); + expect(serialized).to.be.a("string").and.not.empty; + const xml = new DOMParser().parseFromString( + serialized, + "application/xml", + ); + const root = xml.documentElement; + const sectionElement = Array.from(root.children).find( + (child) => child.localName === "section", + ); + const mapOption = PortalOption.findDirectChild( + sectionElement, + "mapConfig", + ); + + expect(root.namespaceURI).to.equal( + "https://purl.dataone.org/portals-1.1.0", + ); + expect(PortalOption.findDirectChild(root, "hideMetrics")).to.exist; + expect(mapOption.children[1].firstChild.nodeType).to.equal(4); + expect(JSON.parse(mapOption.children[1].textContent)).to.deep.equal({}); + + const reloaded = parsePortal(xml); + expect(reloaded.get("hideMetrics")).to.equal(true); + expect(reloaded.get("sections")[0]).to.be.instanceof( + PortalVizSectionModel, + ); + expect(reloaded.get("sections")[0].get("mapModel")).to.be.instanceof(Map); + }); + it("persists Cesium section removal without dropping freeform content", () => { const portal = createMixedPortal(); const mapSection = portal @@ -210,6 +359,7 @@ define([ expect(remainingSection.get("content").get("markdown")).to.equal( "Regular body", ); + expect(reloadedPortal.get("hideData")).to.equal(true); }); }); }); diff --git a/test/js/specs/unit/views/portals/PortalView.spec.js b/test/js/specs/unit/views/portals/PortalView.spec.js index 6fca7b2aea..b15d263fd3 100644 --- a/test/js/specs/unit/views/portals/PortalView.spec.js +++ b/test/js/specs/unit/views/portals/PortalView.spec.js @@ -72,5 +72,22 @@ define([ expect(state.renderMap.calledOnce).to.equal(true); }); + + it("shows an invalid map error without constructing a replacement map", () => { + state.renderMap.restore(); + const section = new PortalVizSectionModel({ + label: "Map", + visualizationType: "cesium", + mapModel: null, + mapConfigError: "The map configuration contains invalid JSON.", + }); + const view = new PortalVisualizationsView({ model: section }); + + view.renderMap(); + + expect(view.$el.text()).to.contain("invalid JSON"); + expect(section.get("mapModel")).to.equal(null); + view.remove(); + }); }); }); diff --git a/test/js/specs/unit/views/portals/editor/PortEditorSectionView.spec.js b/test/js/specs/unit/views/portals/editor/PortEditorSectionView.spec.js index 938f840414..0e70917436 100644 --- a/test/js/specs/unit/views/portals/editor/PortEditorSectionView.spec.js +++ b/test/js/specs/unit/views/portals/editor/PortEditorSectionView.spec.js @@ -1,14 +1,24 @@ define([ "jquery", "backbone", + "models/portals/PortalSectionModel", + "models/portals/PortalVizSectionModel", "views/portals/editor/PortEditorSectionView", "/test/js/specs/shared/clean-state.js", -], ($, Backbone, PortEditorSectionView, cleanState) => { +], ( + $, + Backbone, + PortalSectionModel, + PortalVizSectionModel, + PortEditorSectionView, + cleanState, +) => { const expect = chai.expect; describe("PortEditorSectionView Test Suite", () => { const state = cleanState(() => { const sandbox = sinon.createSandbox(); + const enableCesium = MetacatUI.appModel.get("enableCesium"); const originalTooltip = $.fn.tooltip; $.fn.tooltip = sandbox.stub().callsFake(function tooltipStub() { return this; @@ -23,12 +33,19 @@ define([ view.on("addNewSection", addNewSectionSpy); view.render(); - return { addNewSectionSpy, originalTooltip, sandbox, view }; + return { + addNewSectionSpy, + enableCesium, + originalTooltip, + sandbox, + view, + }; }, beforeEach); afterEach(() => { state.view.off(); state.view.remove(); + MetacatUI.appModel.set("enableCesium", state.enableCesium); $.fn.tooltip = state.originalTooltip; state.sandbox.restore(); }); @@ -49,5 +66,37 @@ define([ expect(state.addNewSectionSpy.called).to.equal(false); }); + + it("tracks Cesium availability independently of other sections", () => { + const freeformSection = new PortalSectionModel(); + const cesiumSection = new PortalVizSectionModel({ + visualizationType: "cesium", + }); + const model = new Backbone.Model({ + hideMetrics: false, + sections: [freeformSection], + }); + const view = new PortEditorSectionView({ model }); + view.render(); + const option = view.$("#section-option-cesium"); + + expect(option.hasClass("disabled")).to.equal(false); + + model.set("sections", [freeformSection, cesiumSection]); + expect(option.hasClass("disabled")).to.equal(true); + + model.set("sections", [freeformSection]); + expect(option.hasClass("disabled")).to.equal(false); + + view.remove(); + }); + + it("does not offer Cesium when it is disabled", () => { + MetacatUI.appModel.set("enableCesium", false); + + state.view.render(); + + expect(state.view.$("#section-option-cesium")).to.have.length(0); + }); }); }); diff --git a/test/js/specs/unit/views/portals/editor/PortEditorSectionsView.spec.js b/test/js/specs/unit/views/portals/editor/PortEditorSectionsView.spec.js index 3b78de8f95..0d8f8df897 100644 --- a/test/js/specs/unit/views/portals/editor/PortEditorSectionsView.spec.js +++ b/test/js/specs/unit/views/portals/editor/PortEditorSectionsView.spec.js @@ -1,14 +1,20 @@ define([ "jquery", "backbone", + "models/maps/Map", "models/portals/PortalSectionModel", + "models/portals/PortalVizSectionModel", + "views/portals/editor/PortEditorMapSectionView", "views/portals/editor/PortEditorMdSectionView", "views/portals/editor/PortEditorSectionsView", "/test/js/specs/shared/clean-state.js", ], ( $, Backbone, + Map, PortalSectionModel, + PortalVizSectionModel, + PortEditorMapSectionView, PortEditorMdSectionView, PortEditorSectionsView, cleanState, @@ -23,12 +29,12 @@ define([ sections: [], }); model.addSection = (sectionType) => { + let section = null; if (sectionType === "freeform") { - model.set("sections", [ - ...model.get("sections"), - new PortalSectionModel(), - ]); + section = new PortalSectionModel(); + model.set("sections", [...model.get("sections"), section]); } + return section; }; model.removeSection = (section) => { model.set( @@ -68,6 +74,22 @@ define([ expect(sectionView.model).to.equal(section); expect(state.view.el.contains(sectionView.el)).to.equal(true); expect(state.view.switchSection.calledWith(sectionView)).to.equal(true); + expect(state.view.updatePageOrder.calledOnce).to.equal(true); + }); + + it("uses the Cesium section map model in its map editor", () => { + const mapModel = new Map(); + const section = new PortalVizSectionModel({ + label: "Map", + visualizationType: "cesium", + mapModel, + }); + + state.view.renderContentSection(section); + + const sectionView = state.view.getSectionByModel(section); + expect(sectionView).to.be.instanceof(PortEditorMapSectionView); + expect(sectionView.mapEditorView.model).to.equal(mapModel); }); it("removes a freeform section", () => { @@ -84,6 +106,12 @@ define([ .data("view", sectionView) .data("section-type", "freeform"); + state.view.removeSection( + { currentTarget: $('')[0] }, + sectionLink, + ); + expect(state.model.get("sections")).to.deep.equal([section]); + state.view.removeSection(null, sectionLink); expect(state.model.get("sections")).to.deep.equal([]); @@ -94,5 +122,36 @@ define([ true, ); }); + + it("places a Cesium section before non-content pages by default", () => { + state.view.addSectionLink.restore(); + state.view.$el.html(` + + `); + + const sectionView = new Backbone.View({ + model: new PortalVizSectionModel({ + label: "Map", + visualizationType: "cesium", + }), + }); + sectionView.type = "PortEditorMapSection"; + sectionView.sectionType = "cesium"; + sectionView.uniqueSectionLabel = "Map"; + + state.view.addSectionLink(sectionView, [], false); + + const sectionNames = state.view + .$(state.view.sectionLinksContainer) + .children() + .map((index, link) => $(link).data("section-name")) + .get(); + expect(sectionNames).to.deep.equal(["Map", "Data", "AddPage"]); + + sectionView.remove(); + }); }); }); diff --git a/test/js/specs/unit/views/portals/editor/PortalEditorView.spec.js b/test/js/specs/unit/views/portals/editor/PortalEditorView.spec.js new file mode 100644 index 0000000000..6dcedf758f --- /dev/null +++ b/test/js/specs/unit/views/portals/editor/PortalEditorView.spec.js @@ -0,0 +1,76 @@ +define([ + "jquery", + "models/portals/PortalModel", + "views/portals/editor/PortalEditorView", +], ($, PortalModel, PortalEditorView) => { + const expect = chai.expect; + + describe("PortalEditorView load errors", () => { + let sandbox; + let view; + let ajax; + let bodyClass; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + bodyClass = document.body.className; + ajax = sandbox.stub($, "ajax"); + sandbox.stub(PortalModel.prototype, "fetchSystemMetadata"); + sandbox.stub(MetacatUI.appView, "listenForActivity"); + sandbox.stub(MetacatUI.appView, "listenForTimeout"); + view = new PortalEditorView({ + el: document.createElement("div"), + portalIdentifier: "test-load-error", + }); + sandbox.stub(view, "authorizeUser").callsFake(function authorize() { + this.model.set("seriesId", "test-load-error"); + this.model.set("isAuthorized", true); + }); + view.render(); + }); + + afterEach(() => { + view.model.stopListening(); + view.model.off(); + view.onClose(); + view.remove(); + document.body.className = bodyClass; + delete window.baseUrl; + sandbox.restore(); + }); + + it("allows portal edits while disabling a map with invalid JSON", () => { + const xml = new DOMParser().parseFromString( + ` + +
+ + Visualization + + + +
+
`, + "application/xml", + ); + expect(view.$el.html()).to.contain("Retrieving portal details"); + + // Simulate a successful XML response containing invalid map JSON. + expect(() => ajax.firstCall.args[0].success(xml)).not.to.throw(); + // Legacy editor rendering leaks this temporary global. + delete window.baseUrl; + + expect(view.$(".loading")).to.have.length(0); + const section = view.model.get("sections")[0]; + const mapView = view.sectionsView.getSectionByModel(section); + expect(mapView.$el.text()).to.match(/map configuration.*invalid JSON/i); + expect(mapView.mapEditorView).to.equal(undefined); + expect(view.$("#save-editor")).to.have.length(1); + view + .$("textarea.portal-title") + .val("Updated portal title") + .trigger("focusout"); + expect(view.model.get("name")).to.equal("Updated portal title"); + }); + }); +});