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
+ hideDatatrue
+ customRootkeep root
`,
"application/xml",
),
);
describe("PortalVizSectionModel Test Suite", () => {
+ it("selects a visualization section by the exact sectionType option", () => {
+ const portal = parsePortal(
+ `
+
+ formatTypeMETADATA
+
+
+ Visualization
+ othersectionTypefreeform
+ sectionTypevisualization
+ visualizationTypecesium
+ mapConfig{"showToolbar":false}
+
+ `,
+ );
+
+ expect(portal.get("sections")[0]).to.be.instanceof(PortalVizSectionModel);
+ });
+
it("preserves Cesium options when an existing section is renamed", () => {
const mapConfig = {
homePosition: {
@@ -95,13 +123,23 @@ define([
sectionType
visualization
+
+ visualizationTypeExtra
+ fever
+
visualizationType
cesium
+ keep visualization extra
+
+
+ mapConfigExtra
+ leave me
mapConfig
${JSON.stringify(mapConfig)}
+ keep map extra
`,
@@ -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
+ sectionTypevisualization
+ visualizationTypecesium
+ mapConfig{invalid}
+
+ `,
+ "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");
+ });
+ });
+});